From 49398b56c8c581f82833439b642da36cbd69c3c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 21:33:11 +0000 Subject: [PATCH 1/6] Add Kamino/Solend-style lending program example A finance/lending Anchor example implementing the core techniques of the most-used Solana lending protocols: - Per-asset reserves with a program-owned liquidity vault and a share-token mint; the share/liquidity exchange rate rises as interest accrues. - Utilization-based kinked interest-rate curve compounded through a cumulative borrow-rate index, with per-obligation scaled debt. - Per-borrower obligations: post share-token collateral, borrow against it up to a loan-to-value limit, repay, withdraw. - Oracle-priced health and close-factor-capped liquidation with a seize bonus. - Switchboard-On-Demand-shaped price feed with a set_price test writer. - Integer-only u128 math (no floats/fixed-point), rounding always in the protocol's favour; available_liquidity as source of truth defeats the empty-pool share-inflation attack. Rust + LiteSVM tests cover supply/redeem, borrow/repay, withdraw, interest accrual, liquidation, the inflation guard, stale reserve/price rejection, and rounding edges (18 tests). https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9 --- finance/lending/anchor/Anchor.toml | 19 + finance/lending/anchor/CHANGELOG.md | 17 + finance/lending/anchor/Cargo.toml | 17 + finance/lending/anchor/README.md | 126 ++++ .../anchor/programs/lending/Cargo.toml | 35 + .../anchor/programs/lending/Xargo.toml | 2 + .../anchor/programs/lending/src/constants.rs | 46 ++ .../anchor/programs/lending/src/errors.rs | 37 + .../instructions/admin/init_lending_market.rs | 32 + .../src/instructions/admin/init_reserve.rs | 78 +++ .../lending/src/instructions/admin/mod.rs | 9 + .../src/instructions/admin/set_price.rs | 55 ++ .../admin/update_reserve_config.rs | 31 + .../borrow_obligation_liquidity.rs | 120 ++++ .../deposit_obligation_collateral.rs | 75 +++ .../instructions/deposit_reserve_liquidity.rs | 100 +++ .../src/instructions/init_obligation.rs | 40 ++ .../src/instructions/liquidate_obligation.rs | 205 ++++++ .../programs/lending/src/instructions/mod.rs | 23 + .../instructions/redeem_reserve_collateral.rs | 103 +++ .../src/instructions/refresh_obligation.rs | 134 ++++ .../src/instructions/refresh_reserve.rs | 16 + .../repay_obligation_liquidity.rs | 99 +++ .../withdraw_obligation_collateral.rs | 125 ++++ .../anchor/programs/lending/src/lib.rs | 101 +++ .../anchor/programs/lending/src/math.rs | 116 ++++ .../lending/src/state/lending_market.rs | 16 + .../anchor/programs/lending/src/state/mod.rs | 9 + .../programs/lending/src/state/obligation.rs | 124 ++++ .../programs/lending/src/state/price_feed.rs | 47 ++ .../programs/lending/src/state/reserve.rs | 209 ++++++ .../programs/lending/tests/common/mod.rs | 630 ++++++++++++++++++ .../lending/tests/test_borrow_repay.rs | 121 ++++ .../lending/tests/test_deposit_redeem.rs | 61 ++ .../programs/lending/tests/test_interest.rs | 63 ++ .../lending/tests/test_liquidation.rs | 100 +++ .../programs/lending/tests/test_reserve.rs | 59 ++ .../programs/lending/tests/test_rounding.rs | 106 +++ 38 files changed, 3306 insertions(+) create mode 100644 finance/lending/anchor/Anchor.toml create mode 100644 finance/lending/anchor/CHANGELOG.md create mode 100644 finance/lending/anchor/Cargo.toml create mode 100644 finance/lending/anchor/README.md create mode 100644 finance/lending/anchor/programs/lending/Cargo.toml create mode 100644 finance/lending/anchor/programs/lending/Xargo.toml create mode 100644 finance/lending/anchor/programs/lending/src/constants.rs create mode 100644 finance/lending/anchor/programs/lending/src/errors.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/admin/init_lending_market.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/admin/mod.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/deposit_obligation_collateral.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/init_obligation.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/mod.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/refresh_reserve.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs create mode 100644 finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs create mode 100644 finance/lending/anchor/programs/lending/src/lib.rs create mode 100644 finance/lending/anchor/programs/lending/src/math.rs create mode 100644 finance/lending/anchor/programs/lending/src/state/lending_market.rs create mode 100644 finance/lending/anchor/programs/lending/src/state/mod.rs create mode 100644 finance/lending/anchor/programs/lending/src/state/obligation.rs create mode 100644 finance/lending/anchor/programs/lending/src/state/price_feed.rs create mode 100644 finance/lending/anchor/programs/lending/src/state/reserve.rs create mode 100644 finance/lending/anchor/programs/lending/tests/common/mod.rs create mode 100644 finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs create mode 100644 finance/lending/anchor/programs/lending/tests/test_deposit_redeem.rs create mode 100644 finance/lending/anchor/programs/lending/tests/test_interest.rs create mode 100644 finance/lending/anchor/programs/lending/tests/test_liquidation.rs create mode 100644 finance/lending/anchor/programs/lending/tests/test_reserve.rs create mode 100644 finance/lending/anchor/programs/lending/tests/test_rounding.rs diff --git a/finance/lending/anchor/Anchor.toml b/finance/lending/anchor/Anchor.toml new file mode 100644 index 00000000..2bc8d887 --- /dev/null +++ b/finance/lending/anchor/Anchor.toml @@ -0,0 +1,19 @@ +[toolchain] +# Pinned to match the rest of solana-program-examples (see tokens/token-swap). +# Unpin when the repo-wide Solana version is bumped. +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +lending = "4bvT6A8S7ZVL6bSvK2KoL2nQ4F5H6AF9133kCYbMJj1t" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +# Anchor 1.0+ runs Rust + LiteSVM tests via cargo test. +test = "cargo test" diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md new file mode 100644 index 00000000..91e0dbb4 --- /dev/null +++ b/finance/lending/anchor/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 0.1.0 + +Initial lending program: a Kamino/Solend-style borrow/lend market. + +- Lending market, per-asset reserves with a program-owned liquidity vault and a + share-token mint, and per-borrower obligations. +- Share-token deposit accounting with an exchange rate driven by accrued interest. +- Utilization-based kinked interest-rate curve compounded through a cumulative + borrow-rate index; per-obligation scaled debt. +- Oracle-priced obligation health with loan-to-value and liquidation-threshold + limits, and close-factor-capped liquidation with a seize bonus. +- Switchboard-On-Demand-shaped price feed with a `set_price` test writer. +- Rust + LiteSVM integration tests covering supply/redeem, borrow/repay, + withdraw, interest accrual, liquidation, the share-inflation guard, and + rounding/stale-input edge cases. diff --git a/finance/lending/anchor/Cargo.toml b/finance/lending/anchor/Cargo.toml new file mode 100644 index 00000000..64a26014 --- /dev/null +++ b/finance/lending/anchor/Cargo.toml @@ -0,0 +1,17 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +# overflow-checks is belt-and-braces: every arithmetic path in the program already +# uses checked_* math, but enabling it means any missed raw op traps instead of wrapping. +overflow-checks = true +lto = "fat" +codegen-units = 1 + +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md new file mode 100644 index 00000000..115684b6 --- /dev/null +++ b/finance/lending/anchor/README.md @@ -0,0 +1,126 @@ +# Lending + +A Kamino/Solend-style borrow/lend program: suppliers earn interest on deposits, +borrowers post collateral and draw other assets against it, and liquidators keep +the market solvent. It demonstrates the techniques the most-used Solana lending +protocols share — share-token deposit accounting, a utilization-based interest +index, oracle-priced obligation health, and close-factor-capped liquidation. + +## Purpose + +Lending markets let one set of users supply liquidity to earn yield while another +set borrows it against collateral. This program implements that end to end: + +- **Suppliers** deposit a token and receive **share tokens** representing their + slice of the pool. The share-to-liquidity exchange rate rises as borrowers pay + interest, so redeeming later returns more than was deposited. +- **Borrowers** post their share tokens as collateral in an obligation and borrow + a different token, up to a loan-to-value limit. +- **Liquidators** repay part of an unhealthy obligation's debt and seize its + collateral at a discount, pulling the position back to solvency. + +Concrete directional example (a short): supply USDC and post the USDC share +tokens as collateral, borrow NVDAx, and sell it. You are **long your collateral +(USDC) and short the borrowed asset (NVDAx)**. While the loan is open you pay a +variable borrow rate that tracks pool utilization. Buy NVDAx back later, call +`repay_obligation_liquidity`, then `withdraw_obligation_collateral` and +`redeem_reserve_collateral` to exit. If NVDAx instead rises far enough, your debt +crosses the liquidation threshold and a liquidator can close part of the position. + +## Major Concepts + +### Accounts + +- **`LendingMarket`** — top-level config (owner, quote-currency mint). PDA seeds + `["lending_market", owner]`. +- **`Reserve`** — one per asset. Owns a program-controlled liquidity vault and a + share-token mint, and stores the interest-rate config, the cumulative borrow- + rate index, available liquidity, and scaled total debt. PDA seeds + `["reserve", market, liquidity_mint]`. +- **`Obligation`** — one per borrower per market: the share-token collateral + posted and the liquidity borrowed, with cached quote-currency valuations. PDA + seeds `["obligation", market, owner]`. +- **`PriceFeed`** — a price for one token (see Oracle below). + +### 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. + +### Interest: a kinked curve and a cumulative index + +Each `refresh_reserve` advances `cumulative_borrow_rate_index` by +`(1 + rate_per_slot * elapsed_slots)`. `rate_per_slot` comes from a kinked +utilization curve — linear from `min_borrow_rate_bps` to `optimal_borrow_rate_bps` +up to `optimal_utilization_bps`, then steeper to `max_borrow_rate_bps` at full +utilization. Each borrow stores its principal as **scaled debt** (principal ÷ +index at borrow time), so every obligation's debt grows automatically as the +index advances — no per-obligation accrual loop. + +### Obligation health + +`refresh_obligation` recomputes, from the refreshed reserves and their prices: +`borrowed_value`, `allowed_borrow_value` (Σ collateral value × `loan_to_value_bps`) +and `unhealthy_borrow_value` (Σ collateral value × `liquidation_threshold_bps`). +Borrowing and withdrawing are gated by `allowed_borrow_value`; an obligation is +liquidatable once `borrowed_value > unhealthy_borrow_value`. Collateral is valued +rounding down and debt rounding up, so health is always judged conservatively. + +### Fixed-point math + +All money math is integer-only `u128` — no floats, no fixed-point crates. Ratios +(rates, the index, the exchange rate, obligation values) are scaled by +`FIXED_POINT_SCALE` (10^18). Every conversion rounds in the protocol's favour +(user output floored, debt ceiled), so dust cannot be extracted by repeated +round-trips. + +### Oracle + +`PriceFeed` mirrors a Switchboard On-Demand pull feed: a signed mantissa, an +exponent (`price = mantissa * 10^exponent`), and the slot the price was written. +Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not wall-clock +time. The `set_price` handler writes the feed directly so the LiteSVM tests are +deterministic; in production a reserve points at the real Switchboard feed and the +program decodes `PullFeedAccountData` (`price_mantissa = current_result.value`, +`exponent = -18`, `last_updated_slot = current_result.slot`) instead. Switchboard +is used rather than Pyth here for its lower compute cost. + +### Custody + +Supplied liquidity sits in program-owned vault PDAs, and posted collateral sits in +per-obligation vault PDAs whose authority is the obligation PDA. The market owner +can update reserve risk parameters (`update_reserve_config`) but has no path to +move user funds — there is no admin withdrawal or escape hatch. + +### Instruction handlers + +Admin: `init_lending_market`, `init_reserve`, `update_reserve_config`, `set_price`. +Supply side: `refresh_reserve`, `deposit_reserve_liquidity`, +`redeem_reserve_collateral`. Borrow side: `init_obligation`, `refresh_obligation`, +`deposit_obligation_collateral`, `withdraw_obligation_collateral`, +`borrow_obligation_liquidity`, `repay_obligation_liquidity`, `liquidate_obligation`. + +Value-dependent handlers require the reserves and the obligation to have been +refreshed in the same transaction, so a typical action transaction is +`[refresh_reserve …, refresh_obligation, ]`. + +## Setup + +- Rust and the Solana toolchain (`cargo-build-sbf`), Anchor 1.0.x, Solana 3.1.8. +- This program has no client/JavaScript code; tests are Rust + LiteSVM. + +## Testing + +```sh +anchor build # or: cargo build-sbf — produces target/deploy/lending.so +anchor test # or: cargo test — runs the LiteSVM integration tests +``` + +`anchor build` (or `cargo build-sbf`) must run first: the tests load the compiled +`target/deploy/lending.so` via `include_bytes!`. The suite covers the +non-happy-path branches — interest accrual, borrowing at the LTV limit, stale +reserve/price rejection, liquidation of an unhealthy obligation after a price +move, the share-inflation guard, and rounding edges. diff --git a/finance/lending/anchor/programs/lending/Cargo.toml b/finance/lending/anchor/programs/lending/Cargo.toml new file mode 100644 index 00000000..723f5ec6 --- /dev/null +++ b/finance/lending/anchor/programs/lending/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "lending" +version = "0.1.0" +description = "Kamino/Solend-style borrow/lend program" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "lending" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +# init-if-needed: the obligation share vault and the test price feed are created lazily. +anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } +anchor-spl = "1.0.0" + +[dev-dependencies] +litesvm = "0.11.0" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +solana-kite = "0.3.0" +borsh = "1.6.1" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/finance/lending/anchor/programs/lending/Xargo.toml b/finance/lending/anchor/programs/lending/Xargo.toml new file mode 100644 index 00000000..475fb71e --- /dev/null +++ b/finance/lending/anchor/programs/lending/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/finance/lending/anchor/programs/lending/src/constants.rs b/finance/lending/anchor/programs/lending/src/constants.rs new file mode 100644 index 00000000..4ff0db05 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/constants.rs @@ -0,0 +1,46 @@ +use anchor_lang::prelude::*; + +/// Fixed-point scale for every ratio in the program: interest rates, the +/// cumulative borrow-rate index, the share-token exchange rate, and obligation +/// values. A ratio `r` is stored as the integer `r * FIXED_POINT_SCALE`. +/// +/// All money math is integer-only (no floats, no fixed-point crates). 10^18 +/// keeps a single slot's interest — which can be a tiny fraction of the index — +/// from truncating to zero, while u128's ~3.4e38 ceiling leaves headroom for the +/// index to grow and for intermediate products before the final narrowing cast. +#[constant] +pub const FIXED_POINT_SCALE: u128 = 1_000_000_000_000_000_000; + +/// log10(FIXED_POINT_SCALE). Used to fold the price exponent and the fixed-point +/// scale into one power of ten so price conversions never form a needless 10^18 +/// intermediate that would overflow for high-priced assets. +pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18; + +/// Denominator for every basis-point config value. 100% == 10_000 bps. +#[constant] +pub const BPS_DENOMINATOR: u128 = 10_000; + +/// Slots per year, for turning an APR (in bps) into a per-slot rate. +/// Solana targets ~2.5 slots/second: 2.5 * 60 * 60 * 24 * 365 = 78_840_000. +#[constant] +pub const SLOTS_PER_YEAR: u128 = 78_840_000; + +/// Maximum distinct reserves an obligation may use as collateral, and +/// separately as borrows. Bounds the account size and the compute cost of +/// refresh_obligation (which iterates every entry). +pub const MAX_OBLIGATION_RESERVES: usize = 4; + +/// A price feed older than this many slots is rejected as stale (~10s at 2.5 +/// slots/second). Freshness is measured in slots, not unix time, because the +/// runtime guarantees slot progression while the timestamp is validator-influenced. +#[constant] +pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25; + +// PDA seeds. +pub const LENDING_MARKET_SEED: &[u8] = b"lending_market"; +pub const RESERVE_SEED: &[u8] = b"reserve"; +pub const LIQUIDITY_VAULT_SEED: &[u8] = b"liquidity_vault"; +pub const SHARE_MINT_SEED: &[u8] = b"share_mint"; +pub const OBLIGATION_SEED: &[u8] = b"obligation"; +pub const OBLIGATION_SHARE_VAULT_SEED: &[u8] = b"obligation_share_vault"; +pub const PRICE_FEED_SEED: &[u8] = b"price_feed"; diff --git a/finance/lending/anchor/programs/lending/src/errors.rs b/finance/lending/anchor/programs/lending/src/errors.rs new file mode 100644 index 00000000..f4624320 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/errors.rs @@ -0,0 +1,37 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum LendingError { + #[msg("Arithmetic operation overflowed")] + MathOverflow, + #[msg("Reserve config has an invalid value")] + InvalidConfig, + #[msg("Amount must be greater than zero")] + ZeroAmount, + #[msg("Deposit is too small to mint any share tokens")] + DepositTooSmall, + #[msg("Reserve does not have enough available liquidity")] + InsufficientReserveLiquidity, + #[msg("Reserve must be refreshed in this same transaction before use")] + ReserveStale, + #[msg("Obligation must be refreshed in this same transaction before use")] + ObligationStale, + #[msg("Price feed has not been updated recently enough")] + StalePriceFeed, + #[msg("Price feed reported a non-positive price")] + InvalidOraclePrice, + #[msg("Borrow would exceed the obligation's allowed borrow value")] + BorrowTooLarge, + #[msg("Withdraw would leave the obligation undercollateralized")] + WithdrawTooLarge, + #[msg("Obligation is healthy and cannot be liquidated")] + ObligationHealthy, + #[msg("Obligation already uses the maximum number of reserves")] + TooManyReserves, + #[msg("Reserve is not part of this obligation")] + ReserveNotFound, + #[msg("A refresh account did not match the obligation's stored reserves")] + InvalidObligationAccount, + #[msg("Signer is not authorized for this price feed")] + UnauthorizedPriceFeed, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/init_lending_market.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/init_lending_market.rs new file mode 100644 index 00000000..d3fba8a8 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/init_lending_market.rs @@ -0,0 +1,32 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::Mint; + +use crate::constants::LENDING_MARKET_SEED; +use crate::state::LendingMarket; + +pub fn handle_init_lending_market(context: Context) -> Result<()> { + let market = &mut context.accounts.lending_market; + market.owner = context.accounts.owner.key(); + market.quote_currency_mint = context.accounts.quote_currency_mint.key(); + market.bump = context.bumps.lending_market; + Ok(()) +} + +#[derive(Accounts)] +pub struct InitLendingMarket<'info> { + #[account( + init, + payer = owner, + space = LendingMarket::DISCRIMINATOR.len() + LendingMarket::INIT_SPACE, + seeds = [LENDING_MARKET_SEED, owner.key().as_ref()], + bump, + )] + pub lending_market: Account<'info, LendingMarket>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub quote_currency_mint: InterfaceAccount<'info, Mint>, + + pub system_program: Program<'info, System>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs new file mode 100644 index 00000000..5f75a6cc --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs @@ -0,0 +1,78 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{ + FIXED_POINT_SCALE, LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, RESERVE_SEED, SHARE_MINT_SEED, +}; +use crate::state::{LendingMarket, PriceFeed, Reserve, ReserveConfig}; + +pub fn handle_init_reserve(context: Context, config: ReserveConfig) -> Result<()> { + config.validate()?; + + let reserve = &mut context.accounts.reserve; + reserve.lending_market = context.accounts.lending_market.key(); + reserve.liquidity_mint = context.accounts.liquidity_mint.key(); + reserve.liquidity_vault = context.accounts.liquidity_vault.key(); + reserve.share_mint = context.accounts.share_mint.key(); + reserve.price_feed = context.accounts.price_feed.key(); + reserve.liquidity_decimals = context.accounts.liquidity_mint.decimals; + reserve.available_liquidity = 0; + reserve.share_mint_supply = 0; + reserve.borrowed_amount_scaled = 0; + reserve.cumulative_borrow_rate_index = FIXED_POINT_SCALE; + reserve.last_update_slot = Clock::get()?.slot; + reserve.config = config; + reserve.bump = context.bumps.reserve; + Ok(()) +} + +#[derive(Accounts)] +pub struct InitReserve<'info> { + #[account( + has_one = owner, + seeds = [LENDING_MARKET_SEED, owner.key().as_ref()], + bump = lending_market.bump, + )] + pub lending_market: Account<'info, LendingMarket>, + + #[account(mut)] + pub owner: Signer<'info>, + + #[account( + init, + payer = owner, + space = Reserve::DISCRIMINATOR.len() + Reserve::INIT_SPACE, + seeds = [RESERVE_SEED, lending_market.key().as_ref(), liquidity_mint.key().as_ref()], + bump, + )] + pub reserve: Account<'info, Reserve>, + + pub liquidity_mint: InterfaceAccount<'info, Mint>, + + #[account( + init, + payer = owner, + token::mint = liquidity_mint, + token::authority = reserve, + seeds = [LIQUIDITY_VAULT_SEED, reserve.key().as_ref()], + bump, + )] + pub liquidity_vault: InterfaceAccount<'info, TokenAccount>, + + #[account( + init, + payer = owner, + mint::decimals = liquidity_mint.decimals, + mint::authority = reserve, + seeds = [SHARE_MINT_SEED, reserve.key().as_ref()], + bump, + )] + pub share_mint: InterfaceAccount<'info, Mint>, + + #[account(constraint = price_feed.mint == liquidity_mint.key() @ crate::errors::LendingError::InvalidConfig)] + pub price_feed: Account<'info, PriceFeed>, + + pub token_program: Interface<'info, TokenInterface>, + + pub system_program: Program<'info, System>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/mod.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/mod.rs new file mode 100644 index 00000000..5737a8a1 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/mod.rs @@ -0,0 +1,9 @@ +pub mod init_lending_market; +pub mod init_reserve; +pub mod set_price; +pub mod update_reserve_config; + +pub use init_lending_market::*; +pub use init_reserve::*; +pub use set_price::*; +pub use update_reserve_config::*; diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs new file mode 100644 index 00000000..e0c8344f --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs @@ -0,0 +1,55 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::Mint; + +use crate::constants::PRICE_FEED_SEED; +use crate::errors::LendingError; +use crate::state::PriceFeed; + +/// Test stand-in for a Switchboard On-Demand feed: writes a price directly so +/// LiteSVM tests are deterministic. In production the reserve points at a real +/// Switchboard feed instead and this handler is unused. +pub fn handle_set_price( + context: Context, + price_mantissa: i128, + exponent: i32, +) -> Result<()> { + let feed = &mut context.accounts.price_feed; + + // On first creation the authority is unset (default Pubkey); claim it for + // the signer. On later updates only that authority may write. + if feed.authority == Pubkey::default() { + feed.authority = context.accounts.authority.key(); + feed.mint = context.accounts.mint.key(); + feed.bump = context.bumps.price_feed; + } else { + require_keys_eq!( + feed.authority, + context.accounts.authority.key(), + LendingError::UnauthorizedPriceFeed + ); + } + + feed.price_mantissa = price_mantissa; + feed.exponent = exponent; + feed.last_updated_slot = Clock::get()?.slot; + Ok(()) +} + +#[derive(Accounts)] +pub struct SetPrice<'info> { + #[account( + init_if_needed, + payer = authority, + space = PriceFeed::DISCRIMINATOR.len() + PriceFeed::INIT_SPACE, + seeds = [PRICE_FEED_SEED, mint.key().as_ref()], + bump, + )] + pub price_feed: Account<'info, PriceFeed>, + + #[account(mut)] + pub authority: Signer<'info>, + + pub mint: InterfaceAccount<'info, Mint>, + + pub system_program: Program<'info, System>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs new file mode 100644 index 00000000..c4ebda7a --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs @@ -0,0 +1,31 @@ +use anchor_lang::prelude::*; + +use crate::constants::LENDING_MARKET_SEED; +use crate::state::{LendingMarket, Reserve, ReserveConfig}; + +pub fn handle_update_reserve_config( + context: Context, + config: ReserveConfig, +) -> Result<()> { + config.validate()?; + context.accounts.reserve.config = config; + Ok(()) +} + +#[derive(Accounts)] +pub struct UpdateReserveConfig<'info> { + #[account( + has_one = owner, + seeds = [LENDING_MARKET_SEED, owner.key().as_ref()], + bump = lending_market.bump, + )] + pub lending_market: Account<'info, LendingMarket>, + + pub owner: Signer<'info>, + + #[account( + mut, + has_one = lending_market, + )] + pub reserve: Account<'info, Reserve>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs new file mode 100644 index 00000000..70a4859d --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs @@ -0,0 +1,120 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::FIXED_POINT_SCALE; +use crate::errors::LendingError; +use crate::math::{market_value, mul_div_ceil, reserve_signer_seeds, Rounding}; +use crate::state::{Obligation, PriceFeed, Reserve}; + +/// Borrow liquidity against the obligation's collateral. The new debt's value +/// (rounded up) plus the existing debt must stay within the obligation's +/// allowed-borrow value. The borrowed amount is recorded as scaled principal at +/// the reserve's current index (rounded up) so it accrues interest going forward. +pub fn handle_borrow_obligation_liquidity( + context: Context, + liquidity_amount: u64, +) -> Result<()> { + require!(liquidity_amount > 0, LendingError::ZeroAmount); + let slot = Clock::get()?.slot; + let reserve_key = context.accounts.reserve.key(); + + context.accounts.obligation.require_refreshed()?; + context.accounts.reserve.require_refreshed()?; + + let price_scaled = context.accounts.price_feed.price_scaled(slot)?; + let decimals = context.accounts.reserve.liquidity_decimals; + let borrow_value = market_value(liquidity_amount, decimals, price_scaled, Rounding::Up)?; + + let projected_borrowed_value = context + .accounts + .obligation + .borrowed_value + .checked_add(borrow_value) + .ok_or(LendingError::MathOverflow)?; + require!( + projected_borrowed_value <= context.accounts.obligation.allowed_borrow_value, + LendingError::BorrowTooLarge + ); + require!( + liquidity_amount <= context.accounts.reserve.available_liquidity, + LendingError::InsufficientReserveLiquidity + ); + + let scaled_added = mul_div_ceil( + liquidity_amount as u128, + FIXED_POINT_SCALE, + context.accounts.reserve.cumulative_borrow_rate_index, + )?; + + { + let reserve = &mut context.accounts.reserve; + reserve.borrowed_amount_scaled = reserve + .borrowed_amount_scaled + .checked_add(scaled_added) + .ok_or(LendingError::MathOverflow)?; + reserve.available_liquidity = reserve + .available_liquidity + .checked_sub(liquidity_amount) + .ok_or(LendingError::MathOverflow)?; + } + + { + let obligation = &mut context.accounts.obligation; + let index = obligation.upsert_borrow(reserve_key)?; + obligation.borrows[index].borrowed_scaled = obligation.borrows[index] + .borrowed_scaled + .checked_add(scaled_added) + .ok_or(LendingError::MathOverflow)?; + obligation.stale = true; + } + + let reserve = &context.accounts.reserve; + let bump = [reserve.bump]; + let seeds = reserve_signer_seeds(&reserve.lending_market, &reserve.liquidity_mint, &bump); + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.liquidity_vault.to_account_info(), + mint: context.accounts.liquidity_mint.to_account_info(), + to: context.accounts.user_liquidity.to_account_info(), + authority: reserve.to_account_info(), + }, + &[&seeds], + ), + liquidity_amount, + decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct BorrowObligationLiquidity<'info> { + #[account(mut, has_one = owner)] + pub obligation: Account<'info, Obligation>, + + pub owner: Signer<'info>, + + #[account( + mut, + has_one = liquidity_mint, + has_one = liquidity_vault, + has_one = price_feed, + )] + pub reserve: Account<'info, Reserve>, + + pub price_feed: Account<'info, PriceFeed>, + + pub liquidity_mint: InterfaceAccount<'info, Mint>, + + #[account(mut)] + pub liquidity_vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub user_liquidity: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/deposit_obligation_collateral.rs b/finance/lending/anchor/programs/lending/src/instructions/deposit_obligation_collateral.rs new file mode 100644 index 00000000..01bc1610 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/deposit_obligation_collateral.rs @@ -0,0 +1,75 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::OBLIGATION_SHARE_VAULT_SEED; +use crate::errors::LendingError; +use crate::state::{Obligation, Reserve}; + +/// Post share tokens as collateral. The shares move into a per-(reserve, +/// obligation) vault owned by the obligation PDA. No health check is needed — +/// adding collateral only improves health — but the obligation is marked stale +/// so its cached values are recomputed before the next health-dependent action. +pub fn handle_deposit_obligation_collateral( + context: Context, + share_amount: u64, +) -> Result<()> { + require!(share_amount > 0, LendingError::ZeroAmount); + + let reserve_key = context.accounts.reserve.key(); + let obligation = &mut context.accounts.obligation; + let index = obligation.upsert_collateral(reserve_key)?; + obligation.deposits[index].deposited_shares = obligation.deposits[index] + .deposited_shares + .checked_add(share_amount) + .ok_or(LendingError::MathOverflow)?; + obligation.stale = true; + + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.user_share.to_account_info(), + mint: context.accounts.share_mint.to_account_info(), + to: context.accounts.obligation_share_vault.to_account_info(), + authority: context.accounts.owner.to_account_info(), + }, + ), + share_amount, + context.accounts.share_mint.decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct DepositObligationCollateral<'info> { + #[account(mut, has_one = owner)] + pub obligation: Account<'info, Obligation>, + + #[account(mut)] + pub owner: Signer<'info>, + + #[account(has_one = share_mint)] + pub reserve: Account<'info, Reserve>, + + pub share_mint: InterfaceAccount<'info, Mint>, + + #[account( + init_if_needed, + payer = owner, + token::mint = share_mint, + token::authority = obligation, + seeds = [OBLIGATION_SHARE_VAULT_SEED, reserve.key().as_ref(), obligation.key().as_ref()], + bump, + )] + pub obligation_share_vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub user_share: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, + + pub system_program: Program<'info, System>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs new file mode 100644 index 00000000..7ae43702 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs @@ -0,0 +1,100 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + mint_to, transfer_checked, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::errors::LendingError; +use crate::math::{mul_div_floor, reserve_signer_seeds}; +use crate::state::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. +pub fn handle_deposit_reserve_liquidity( + context: Context, + liquidity_amount: u64, +) -> Result<()> { + require!(liquidity_amount > 0, LendingError::ZeroAmount); + 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 + } else { + mul_div_floor(liquidity_amount as u128, share_supply, reserve.total_liquidity()?)? + }; + require!(share_amount > 0, LendingError::DepositTooSmall); + let share_amount = u64::try_from(share_amount).map_err(|_| LendingError::MathOverflow)?; + + // Effects before interactions. + reserve.available_liquidity = reserve + .available_liquidity + .checked_add(liquidity_amount) + .ok_or(LendingError::MathOverflow)?; + reserve.share_mint_supply = reserve + .share_mint_supply + .checked_add(share_amount) + .ok_or(LendingError::MathOverflow)?; + + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.user_liquidity.to_account_info(), + mint: context.accounts.liquidity_mint.to_account_info(), + to: context.accounts.liquidity_vault.to_account_info(), + authority: context.accounts.owner.to_account_info(), + }, + ), + liquidity_amount, + reserve.liquidity_decimals, + )?; + + let bump = [reserve.bump]; + let seeds = reserve_signer_seeds(&reserve.lending_market, &reserve.liquidity_mint, &bump); + mint_to( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + MintTo { + mint: context.accounts.share_mint.to_account_info(), + to: context.accounts.user_share.to_account_info(), + authority: reserve.to_account_info(), + }, + &[&seeds], + ), + share_amount, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct DepositReserveLiquidity<'info> { + #[account( + mut, + has_one = liquidity_mint, + has_one = liquidity_vault, + has_one = share_mint, + )] + pub reserve: Account<'info, Reserve>, + + pub liquidity_mint: InterfaceAccount<'info, Mint>, + + #[account(mut)] + pub liquidity_vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub share_mint: InterfaceAccount<'info, Mint>, + + #[account(mut)] + pub user_liquidity: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub user_share: InterfaceAccount<'info, TokenAccount>, + + pub owner: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/init_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/init_obligation.rs new file mode 100644 index 00000000..07b5d523 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/init_obligation.rs @@ -0,0 +1,40 @@ +use anchor_lang::prelude::*; + +use crate::constants::OBLIGATION_SEED; +use crate::state::{LendingMarket, Obligation}; + +pub fn handle_init_obligation(context: Context) -> Result<()> { + let obligation = &mut context.accounts.obligation; + obligation.lending_market = context.accounts.lending_market.key(); + obligation.owner = context.accounts.owner.key(); + obligation.last_update_slot = Clock::get()?.slot; + // Stale until the first refresh; an empty obligation has nothing to value yet. + obligation.stale = true; + obligation.deposited_value = 0; + obligation.borrowed_value = 0; + obligation.allowed_borrow_value = 0; + obligation.unhealthy_borrow_value = 0; + obligation.deposits = Vec::new(); + obligation.borrows = Vec::new(); + obligation.bump = context.bumps.obligation; + Ok(()) +} + +#[derive(Accounts)] +pub struct InitObligation<'info> { + pub lending_market: Account<'info, LendingMarket>, + + #[account( + init, + payer = owner, + space = Obligation::DISCRIMINATOR.len() + Obligation::INIT_SPACE, + seeds = [OBLIGATION_SEED, lending_market.key().as_ref(), owner.key().as_ref()], + bump, + )] + pub obligation: Account<'info, Obligation>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs new file mode 100644 index 00000000..ec257b74 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs @@ -0,0 +1,205 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED}; +use crate::errors::LendingError; +use crate::math::{market_value, mul_div_ceil, mul_div_floor, value_to_amount, Rounding}; +use crate::state::{Obligation, PriceFeed, Reserve}; + +/// Repay part of an unhealthy obligation's debt and seize a matching amount of +/// its collateral share tokens plus a bonus. A single liquidation may repay at +/// most the collateral reserve's close factor of the borrow. The seized +/// collateral is valued at the borrow repaid plus the liquidation bonus, all +/// rounded toward the borrower so the obligation is never over-seized. +/// +/// Self-liquidation (the owner liquidating their own position) is not blocked: +/// it is only possible while unhealthy and is economically pointless, matching +/// how Solend and Kamino behave. +pub fn handle_liquidate_obligation( + context: Context, + liquidity_amount: u64, +) -> Result<()> { + require!(liquidity_amount > 0, LendingError::ZeroAmount); + let slot = Clock::get()?.slot; + + context.accounts.obligation.require_refreshed()?; + context.accounts.repay_reserve.require_refreshed()?; + context.accounts.collateral_reserve.require_refreshed()?; + + require!( + context.accounts.obligation.borrowed_value > context.accounts.obligation.unhealthy_borrow_value, + LendingError::ObligationHealthy + ); + + let repay_reserve_key = context.accounts.repay_reserve.key(); + let collateral_reserve_key = context.accounts.collateral_reserve.key(); + let repay_price = context.accounts.repay_price_feed.price_scaled(slot)?; + let collateral_price = context.accounts.collateral_price_feed.price_scaled(slot)?; + + let borrow_index = context.accounts.obligation.find_borrow(repay_reserve_key)?; + let collateral_index = context.accounts.obligation.find_collateral(collateral_reserve_key)?; + let borrowed_scaled = context.accounts.obligation.borrows[borrow_index].borrowed_scaled; + let deposited_shares = context.accounts.obligation.deposits[collateral_index].deposited_shares; + + // How much debt this liquidation repays, capped by the close factor. + let interest_index = context.accounts.repay_reserve.cumulative_borrow_rate_index; + let debt_now = mul_div_ceil(borrowed_scaled, interest_index, FIXED_POINT_SCALE)?; + let debt_now = u64::try_from(debt_now).map_err(|_| LendingError::MathOverflow)?; + let max_repay = mul_div_floor( + debt_now as u128, + context.accounts.collateral_reserve.config.close_factor_bps as u128, + BPS_DENOMINATOR, + )?; + let repay = liquidity_amount.min(u64::try_from(max_repay).map_err(|_| LendingError::MathOverflow)?); + require!(repay > 0, LendingError::ZeroAmount); + + // Collateral to seize: value of the repayment plus the bonus, converted into + // the collateral token and then into share tokens. Every step rounds down. + let repay_value = market_value( + repay, + context.accounts.repay_reserve.liquidity_decimals, + repay_price, + Rounding::Down, + )?; + let bonus_value = mul_div_floor( + repay_value, + context.accounts.collateral_reserve.config.liquidation_bonus_bps as u128, + BPS_DENOMINATOR, + )?; + let seize_value = repay_value + .checked_add(bonus_value) + .ok_or(LendingError::MathOverflow)?; + let seize_liquidity = value_to_amount( + seize_value, + context.accounts.collateral_reserve.liquidity_decimals, + collateral_price, + Rounding::Down, + )?; + let seize_shares = mul_div_floor( + seize_liquidity as u128, + context.accounts.collateral_reserve.share_mint_supply as u128, + context.accounts.collateral_reserve.total_liquidity()?.max(1), + )?; + let seize_shares = u64::try_from(seize_shares) + .map_err(|_| LendingError::MathOverflow)? + .min(deposited_shares); + require!(seize_shares > 0, LendingError::ZeroAmount); + + let scaled_removed = + mul_div_floor(repay as u128, FIXED_POINT_SCALE, interest_index)?.min(borrowed_scaled); + + // Effects: repay side. + { + let repay_reserve = &mut context.accounts.repay_reserve; + repay_reserve.borrowed_amount_scaled = repay_reserve + .borrowed_amount_scaled + .checked_sub(scaled_removed) + .ok_or(LendingError::MathOverflow)?; + repay_reserve.available_liquidity = repay_reserve + .available_liquidity + .checked_add(repay) + .ok_or(LendingError::MathOverflow)?; + } + + // Effects: obligation debt and collateral. + let (lending_market, owner, obligation_bump) = { + let obligation = &mut context.accounts.obligation; + obligation.borrows[borrow_index].borrowed_scaled = borrowed_scaled + .checked_sub(scaled_removed) + .ok_or(LendingError::MathOverflow)?; + if obligation.borrows[borrow_index].borrowed_scaled == 0 { + obligation.borrows.remove(borrow_index); + } + obligation.deposits[collateral_index].deposited_shares = deposited_shares + .checked_sub(seize_shares) + .ok_or(LendingError::MathOverflow)?; + if obligation.deposits[collateral_index].deposited_shares == 0 { + obligation.deposits.remove(collateral_index); + } + obligation.stale = true; + (obligation.lending_market, obligation.owner, obligation.bump) + }; + + // Interactions: liquidator repays, then receives the seized share tokens. + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.liquidator_repay_source.to_account_info(), + mint: context.accounts.repay_liquidity_mint.to_account_info(), + to: context.accounts.repay_liquidity_vault.to_account_info(), + authority: context.accounts.liquidator.to_account_info(), + }, + ), + repay, + context.accounts.repay_reserve.liquidity_decimals, + )?; + + let bump = [obligation_bump]; + let seeds: [&[u8]; 4] = [OBLIGATION_SEED, lending_market.as_ref(), owner.as_ref(), &bump]; + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.obligation_collateral_vault.to_account_info(), + mint: context.accounts.collateral_share_mint.to_account_info(), + to: context.accounts.liquidator_collateral_dest.to_account_info(), + authority: context.accounts.obligation.to_account_info(), + }, + &[&seeds], + ), + seize_shares, + context.accounts.collateral_share_mint.decimals, + )?; + + Ok(()) +} + +// Liquidation touches 13 accounts; every Account/InterfaceAccount is boxed so +// account deserialization happens on the heap and stays within the BPF stack frame. +#[derive(Accounts)] +pub struct LiquidateObligation<'info> { + #[account(mut)] + pub obligation: Box>, + + pub liquidator: Signer<'info>, + + #[account(mut)] + pub repay_reserve: Box>, + + pub collateral_reserve: Box>, + + #[account(address = repay_reserve.price_feed)] + pub repay_price_feed: Box>, + + #[account(address = collateral_reserve.price_feed)] + pub collateral_price_feed: Box>, + + #[account(address = repay_reserve.liquidity_mint)] + pub repay_liquidity_mint: Box>, + + #[account(address = collateral_reserve.share_mint)] + pub collateral_share_mint: Box>, + + #[account(mut, address = repay_reserve.liquidity_vault)] + pub repay_liquidity_vault: Box>, + + #[account( + mut, + seeds = [OBLIGATION_SHARE_VAULT_SEED, collateral_reserve.key().as_ref(), obligation.key().as_ref()], + bump, + token::mint = collateral_share_mint, + token::authority = obligation, + )] + pub obligation_collateral_vault: Box>, + + #[account(mut)] + pub liquidator_repay_source: Box>, + + #[account(mut)] + pub liquidator_collateral_dest: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/mod.rs b/finance/lending/anchor/programs/lending/src/instructions/mod.rs new file mode 100644 index 00000000..a1905200 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/mod.rs @@ -0,0 +1,23 @@ +pub mod admin; +pub mod borrow_obligation_liquidity; +pub mod deposit_obligation_collateral; +pub mod deposit_reserve_liquidity; +pub mod init_obligation; +pub mod liquidate_obligation; +pub mod redeem_reserve_collateral; +pub mod refresh_obligation; +pub mod refresh_reserve; +pub mod repay_obligation_liquidity; +pub mod withdraw_obligation_collateral; + +pub use admin::*; +pub use borrow_obligation_liquidity::*; +pub use deposit_obligation_collateral::*; +pub use deposit_reserve_liquidity::*; +pub use init_obligation::*; +pub use liquidate_obligation::*; +pub use redeem_reserve_collateral::*; +pub use refresh_obligation::*; +pub use refresh_reserve::*; +pub use repay_obligation_liquidity::*; +pub use withdraw_obligation_collateral::*; diff --git a/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs b/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs new file mode 100644 index 00000000..6d0309b0 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs @@ -0,0 +1,103 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + burn, transfer_checked, Burn, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::errors::LendingError; +use crate::math::{mul_div_floor, reserve_signer_seeds}; +use crate::state::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) +/// liquidity. +pub fn handle_redeem_reserve_collateral( + context: Context, + share_amount: u64, +) -> Result<()> { + require!(share_amount > 0, LendingError::ZeroAmount); + let reserve = &mut context.accounts.reserve; + reserve.require_refreshed()?; + + let share_supply = reserve.share_mint_supply as u128; + require!(share_supply > 0, LendingError::InsufficientReserveLiquidity); + let liquidity_amount = mul_div_floor( + share_amount as u128, + reserve.total_liquidity()?, + share_supply, + )?; + let liquidity_amount = u64::try_from(liquidity_amount).map_err(|_| LendingError::MathOverflow)?; + require!( + liquidity_amount <= reserve.available_liquidity, + LendingError::InsufficientReserveLiquidity + ); + + reserve.available_liquidity = reserve + .available_liquidity + .checked_sub(liquidity_amount) + .ok_or(LendingError::MathOverflow)?; + reserve.share_mint_supply = reserve + .share_mint_supply + .checked_sub(share_amount) + .ok_or(LendingError::MathOverflow)?; + + burn( + CpiContext::new( + context.accounts.token_program.key(), + Burn { + mint: context.accounts.share_mint.to_account_info(), + from: context.accounts.user_share.to_account_info(), + authority: context.accounts.owner.to_account_info(), + }, + ), + share_amount, + )?; + + let bump = [reserve.bump]; + let seeds = reserve_signer_seeds(&reserve.lending_market, &reserve.liquidity_mint, &bump); + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.liquidity_vault.to_account_info(), + mint: context.accounts.liquidity_mint.to_account_info(), + to: context.accounts.user_liquidity.to_account_info(), + authority: reserve.to_account_info(), + }, + &[&seeds], + ), + liquidity_amount, + reserve.liquidity_decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct RedeemReserveCollateral<'info> { + #[account( + mut, + has_one = liquidity_mint, + has_one = liquidity_vault, + has_one = share_mint, + )] + pub reserve: Account<'info, Reserve>, + + pub liquidity_mint: InterfaceAccount<'info, Mint>, + + #[account(mut)] + pub liquidity_vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub share_mint: InterfaceAccount<'info, Mint>, + + #[account(mut)] + pub user_liquidity: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub user_share: InterfaceAccount<'info, TokenAccount>, + + pub owner: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs new file mode 100644 index 00000000..440c5096 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs @@ -0,0 +1,134 @@ +use anchor_lang::prelude::*; + +use crate::constants::BPS_DENOMINATOR; +use crate::errors::LendingError; +use crate::math::{market_value, mul_div_ceil, mul_div_floor, Rounding}; +use crate::state::{Obligation, PriceFeed, Reserve}; + +/// Recompute the obligation's deposited/borrowed values and its borrow limits +/// from the current state of every reserve it touches. +/// +/// The reserve and price-feed accounts are passed as `remaining_accounts`, two +/// per entry — first the deposit reserves in `obligation.deposits` order, then +/// the borrow reserves in `obligation.borrows` order — each as +/// `[reserve, price_feed]`. Every reserve must already be refreshed this slot. +/// +/// Collateral value is floored and debt value is ceiled, so health is always +/// evaluated conservatively against the borrower. +pub fn handle_refresh_obligation(context: Context) -> Result<()> { + let slot = Clock::get()?.slot; + let obligation = &mut context.accounts.obligation; + let accounts = context.remaining_accounts; + let mut cursor = 0usize; + + let mut deposited_value: u128 = 0; + let mut allowed_borrow_value: u128 = 0; + let mut unhealthy_borrow_value: u128 = 0; + + for collateral in obligation.deposits.iter_mut() { + let (reserve, price_scaled) = read_pair(accounts, &mut cursor, collateral.reserve, slot)?; + + let liquidity = mul_div_floor( + collateral.deposited_shares as u128, + reserve.total_liquidity()?, + (reserve.share_mint_supply as u128).max(1), + )?; + let liquidity = u64::try_from(liquidity).map_err(|_| LendingError::MathOverflow)?; + let value = market_value(liquidity, reserve.liquidity_decimals, price_scaled, Rounding::Down)?; + + collateral.market_value = value; + deposited_value = deposited_value + .checked_add(value) + .ok_or(LendingError::MathOverflow)?; + allowed_borrow_value = allowed_borrow_value + .checked_add(mul_div_floor( + value, + reserve.config.loan_to_value_bps as u128, + BPS_DENOMINATOR, + )?) + .ok_or(LendingError::MathOverflow)?; + unhealthy_borrow_value = unhealthy_borrow_value + .checked_add(mul_div_floor( + value, + reserve.config.liquidation_threshold_bps as u128, + BPS_DENOMINATOR, + )?) + .ok_or(LendingError::MathOverflow)?; + } + + let mut borrowed_value: u128 = 0; + for borrow in obligation.borrows.iter_mut() { + let (reserve, price_scaled) = read_pair(accounts, &mut cursor, borrow.reserve, slot)?; + + let debt = mul_div_ceil( + borrow.borrowed_scaled, + reserve.cumulative_borrow_rate_index, + crate::constants::FIXED_POINT_SCALE, + )?; + let debt = u64::try_from(debt).map_err(|_| LendingError::MathOverflow)?; + let value = market_value(debt, reserve.liquidity_decimals, price_scaled, Rounding::Up)?; + + borrow.market_value = value; + borrowed_value = borrowed_value + .checked_add(value) + .ok_or(LendingError::MathOverflow)?; + } + + require!( + cursor == accounts.len(), + LendingError::InvalidObligationAccount + ); + + obligation.deposited_value = deposited_value; + obligation.allowed_borrow_value = allowed_borrow_value; + obligation.unhealthy_borrow_value = unhealthy_borrow_value; + obligation.borrowed_value = borrowed_value; + obligation.last_update_slot = slot; + obligation.stale = false; + Ok(()) +} + +/// Read the next `[reserve, price_feed]` pair from `remaining_accounts`, +/// checking it matches the obligation's stored reserve and that both the +/// reserve (refreshed this slot) and the price (fresh) are usable. +fn read_pair<'a, 'info>( + accounts: &'a [AccountInfo<'info>], + cursor: &mut usize, + expected_reserve: Pubkey, + slot: u64, +) -> Result<(Reserve, u128)> +where + 'a: 'info, +{ + let reserve_info = accounts + .get(*cursor) + .ok_or(LendingError::InvalidObligationAccount)?; + let price_info = accounts + .get(*cursor + 1) + .ok_or(LendingError::InvalidObligationAccount)?; + *cursor += 2; + + require_keys_eq!( + reserve_info.key(), + expected_reserve, + LendingError::InvalidObligationAccount + ); + let reserve = Account::::try_from(reserve_info)?; + reserve.require_refreshed()?; + + require_keys_eq!( + price_info.key(), + reserve.price_feed, + LendingError::InvalidObligationAccount + ); + let price_feed = Account::::try_from(price_info)?; + let price_scaled = price_feed.price_scaled(slot)?; + + Ok((reserve.into_inner(), price_scaled)) +} + +#[derive(Accounts)] +pub struct RefreshObligation<'info> { + #[account(mut)] + pub obligation: Account<'info, Obligation>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/refresh_reserve.rs b/finance/lending/anchor/programs/lending/src/instructions/refresh_reserve.rs new file mode 100644 index 00000000..151f095a --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/refresh_reserve.rs @@ -0,0 +1,16 @@ +use anchor_lang::prelude::*; + +use crate::state::Reserve; + +/// Accrue interest up to the current slot. Must run (as its own instruction in +/// the same transaction) before any handler that reads the reserve's value, and +/// before `refresh_obligation` for any reserve the obligation touches. +pub fn handle_refresh_reserve(context: Context) -> Result<()> { + context.accounts.reserve.accrue_interest(Clock::get()?.slot) +} + +#[derive(Accounts)] +pub struct RefreshReserve<'info> { + #[account(mut)] + pub reserve: Account<'info, Reserve>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs new file mode 100644 index 00000000..acce54e1 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs @@ -0,0 +1,99 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::FIXED_POINT_SCALE; +use crate::errors::LendingError; +use crate::math::{mul_div_ceil, mul_div_floor}; +use crate::state::{Obligation, Reserve}; + +/// Repay borrowed liquidity, clamped to the live debt. The repaid amount removes +/// scaled principal rounded down, so any sub-unit of principal lingers with the +/// borrower rather than being forgiven by rounding. Anyone may repay on behalf +/// of an obligation, so there is no owner check. +pub fn handle_repay_obligation_liquidity( + context: Context, + liquidity_amount: u64, +) -> Result<()> { + require!(liquidity_amount > 0, LendingError::ZeroAmount); + let reserve_key = context.accounts.reserve.key(); + context.accounts.reserve.require_refreshed()?; + + let index = context.accounts.reserve.cumulative_borrow_rate_index; + let decimals = context.accounts.reserve.liquidity_decimals; + + let borrow_index = context.accounts.obligation.find_borrow(reserve_key)?; + let borrowed_scaled = context.accounts.obligation.borrows[borrow_index].borrowed_scaled; + + let debt_now = mul_div_ceil(borrowed_scaled, index, FIXED_POINT_SCALE)?; + let debt_now = u64::try_from(debt_now).map_err(|_| LendingError::MathOverflow)?; + let repay = liquidity_amount.min(debt_now); + require!(repay > 0, LendingError::ZeroAmount); + + let scaled_removed = mul_div_floor(repay as u128, FIXED_POINT_SCALE, index)?.min(borrowed_scaled); + + { + let reserve = &mut context.accounts.reserve; + reserve.borrowed_amount_scaled = reserve + .borrowed_amount_scaled + .checked_sub(scaled_removed) + .ok_or(LendingError::MathOverflow)?; + reserve.available_liquidity = reserve + .available_liquidity + .checked_add(repay) + .ok_or(LendingError::MathOverflow)?; + } + + { + let obligation = &mut context.accounts.obligation; + obligation.borrows[borrow_index].borrowed_scaled = borrowed_scaled + .checked_sub(scaled_removed) + .ok_or(LendingError::MathOverflow)?; + if obligation.borrows[borrow_index].borrowed_scaled == 0 { + obligation.borrows.remove(borrow_index); + } + obligation.stale = true; + } + + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.user_liquidity.to_account_info(), + mint: context.accounts.liquidity_mint.to_account_info(), + to: context.accounts.liquidity_vault.to_account_info(), + authority: context.accounts.repayer.to_account_info(), + }, + ), + repay, + decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct RepayObligationLiquidity<'info> { + #[account(mut)] + pub obligation: Account<'info, Obligation>, + + #[account( + mut, + has_one = liquidity_mint, + has_one = liquidity_vault, + )] + pub reserve: Account<'info, Reserve>, + + pub liquidity_mint: InterfaceAccount<'info, Mint>, + + #[account(mut)] + pub liquidity_vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub user_liquidity: InterfaceAccount<'info, TokenAccount>, + + pub repayer: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs b/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs new file mode 100644 index 00000000..c272aac3 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs @@ -0,0 +1,125 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::{BPS_DENOMINATOR, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED}; +use crate::errors::LendingError; +use crate::math::{market_value, mul_div_floor, Rounding}; +use crate::state::{Obligation, PriceFeed, Reserve}; + +/// Withdraw posted share-token collateral, but only as long as the obligation +/// stays within its borrow limit afterwards. The post-withdraw allowed-borrow +/// value is simulated and the withdraw is rejected if the existing debt would +/// exceed it. +pub fn handle_withdraw_obligation_collateral( + context: Context, + share_amount: u64, +) -> Result<()> { + require!(share_amount > 0, LendingError::ZeroAmount); + let slot = Clock::get()?.slot; + + context.accounts.obligation.require_refreshed()?; + context.accounts.reserve.require_refreshed()?; + let reserve = &context.accounts.reserve; + let price_scaled = context.accounts.price_feed.price_scaled(slot)?; + + let obligation = &mut context.accounts.obligation; + let index = obligation.find_collateral(reserve.key())?; + require!( + obligation.deposits[index].deposited_shares >= share_amount, + LendingError::WithdrawTooLarge + ); + + // Value of the collateral being removed, and the borrow power it backed. + let removed_liquidity = mul_div_floor( + share_amount as u128, + reserve.total_liquidity()?, + (reserve.share_mint_supply as u128).max(1), + )?; + let removed_liquidity = u64::try_from(removed_liquidity).map_err(|_| LendingError::MathOverflow)?; + let removed_value = market_value( + removed_liquidity, + reserve.liquidity_decimals, + price_scaled, + Rounding::Down, + )?; + let removed_allowed = mul_div_floor( + removed_value, + reserve.config.loan_to_value_bps as u128, + BPS_DENOMINATOR, + )?; + let new_allowed_borrow_value = obligation + .allowed_borrow_value + .checked_sub(removed_allowed) + .ok_or(LendingError::MathOverflow)?; + require!( + obligation.borrowed_value <= new_allowed_borrow_value, + LendingError::WithdrawTooLarge + ); + + // Effects. + obligation.deposits[index].deposited_shares = obligation.deposits[index] + .deposited_shares + .checked_sub(share_amount) + .ok_or(LendingError::MathOverflow)?; + if obligation.deposits[index].deposited_shares == 0 { + obligation.deposits.remove(index); + } + obligation.stale = true; + + let lending_market = obligation.lending_market; + let owner = obligation.owner; + let bump = [obligation.bump]; + let seeds: [&[u8]; 4] = [ + OBLIGATION_SEED, + lending_market.as_ref(), + owner.as_ref(), + &bump, + ]; + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.obligation_share_vault.to_account_info(), + mint: context.accounts.share_mint.to_account_info(), + to: context.accounts.user_share.to_account_info(), + authority: obligation.to_account_info(), + }, + &[&seeds], + ), + share_amount, + context.accounts.share_mint.decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct WithdrawObligationCollateral<'info> { + #[account(mut, has_one = owner)] + pub obligation: Account<'info, Obligation>, + + pub owner: Signer<'info>, + + #[account(has_one = share_mint, has_one = price_feed)] + pub reserve: Account<'info, Reserve>, + + pub price_feed: Account<'info, PriceFeed>, + + pub share_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + seeds = [OBLIGATION_SHARE_VAULT_SEED, reserve.key().as_ref(), obligation.key().as_ref()], + bump, + token::mint = share_mint, + token::authority = obligation, + )] + pub obligation_share_vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub user_share: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/lending/anchor/programs/lending/src/lib.rs b/finance/lending/anchor/programs/lending/src/lib.rs new file mode 100644 index 00000000..e2892ca6 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/lib.rs @@ -0,0 +1,101 @@ +use anchor_lang::prelude::*; + +pub mod constants; +pub mod errors; +pub mod math; +pub mod instructions; +pub mod state; + +use instructions::*; +use state::ReserveConfig; + +declare_id!("4bvT6A8S7ZVL6bSvK2KoL2nQ4F5H6AF9133kCYbMJj1t"); + +#[program] +pub mod lending { + use super::*; + + pub fn init_lending_market(context: Context) -> Result<()> { + instructions::handle_init_lending_market(context) + } + + pub fn init_reserve(context: Context, config: ReserveConfig) -> Result<()> { + instructions::handle_init_reserve(context, config) + } + + pub fn update_reserve_config( + context: Context, + config: ReserveConfig, + ) -> Result<()> { + instructions::handle_update_reserve_config(context, config) + } + + pub fn set_price( + context: Context, + price_mantissa: i128, + exponent: i32, + ) -> Result<()> { + instructions::handle_set_price(context, price_mantissa, exponent) + } + + pub fn refresh_reserve(context: Context) -> Result<()> { + instructions::handle_refresh_reserve(context) + } + + pub fn deposit_reserve_liquidity( + context: Context, + liquidity_amount: u64, + ) -> Result<()> { + instructions::handle_deposit_reserve_liquidity(context, liquidity_amount) + } + + pub fn redeem_reserve_collateral( + context: Context, + share_amount: u64, + ) -> Result<()> { + instructions::handle_redeem_reserve_collateral(context, share_amount) + } + + pub fn init_obligation(context: Context) -> Result<()> { + instructions::handle_init_obligation(context) + } + + pub fn refresh_obligation(context: Context) -> Result<()> { + instructions::handle_refresh_obligation(context) + } + + pub fn deposit_obligation_collateral( + context: Context, + share_amount: u64, + ) -> Result<()> { + instructions::handle_deposit_obligation_collateral(context, share_amount) + } + + pub fn withdraw_obligation_collateral( + context: Context, + share_amount: u64, + ) -> Result<()> { + instructions::handle_withdraw_obligation_collateral(context, share_amount) + } + + pub fn borrow_obligation_liquidity( + context: Context, + liquidity_amount: u64, + ) -> Result<()> { + instructions::handle_borrow_obligation_liquidity(context, liquidity_amount) + } + + pub fn repay_obligation_liquidity( + context: Context, + liquidity_amount: u64, + ) -> Result<()> { + instructions::handle_repay_obligation_liquidity(context, liquidity_amount) + } + + pub fn liquidate_obligation( + context: Context, + liquidity_amount: u64, + ) -> Result<()> { + instructions::handle_liquidate_obligation(context, liquidity_amount) + } +} diff --git a/finance/lending/anchor/programs/lending/src/math.rs b/finance/lending/anchor/programs/lending/src/math.rs new file mode 100644 index 00000000..7731fec4 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/math.rs @@ -0,0 +1,116 @@ +use anchor_lang::prelude::*; + +use crate::constants::{FIXED_POINT_SCALE_DECIMALS, RESERVE_SEED}; +use crate::errors::LendingError; + +/// Which way to break ties when a division truncates. Deposits/redeems and +/// collateral valuations round the user's favourable quantity DOWN; debt and +/// protocol-owed quantities round UP. The protocol never loses a base unit to +/// rounding, so dust cannot be extracted by repeated round-trips. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Rounding { + Down, + Up, +} + +/// 10^exponent as a u128, erroring instead of wrapping. +pub fn ten_pow(exponent: u32) -> Result { + Ok(10u128 + .checked_pow(exponent) + .ok_or(LendingError::MathOverflow)?) +} + +/// floor((a * b) / denominator), computed in u128. +pub fn mul_div_floor(a: u128, b: u128, denominator: u128) -> Result { + require!(denominator > 0, LendingError::MathOverflow); + let product = a.checked_mul(b).ok_or(LendingError::MathOverflow)?; + Ok(product + .checked_div(denominator) + .ok_or(LendingError::MathOverflow)?) +} + +/// ceil((a * b) / denominator), computed in u128. +pub fn mul_div_ceil(a: u128, b: u128, denominator: u128) -> Result { + require!(denominator > 0, LendingError::MathOverflow); + let product = a.checked_mul(b).ok_or(LendingError::MathOverflow)?; + let rounding = denominator + .checked_sub(1) + .ok_or(LendingError::MathOverflow)?; + Ok(product + .checked_add(rounding) + .ok_or(LendingError::MathOverflow)? + .checked_div(denominator) + .ok_or(LendingError::MathOverflow)?) +} + +fn mul_div(a: u128, b: u128, denominator: u128, rounding: Rounding) -> Result { + match rounding { + Rounding::Down => mul_div_floor(a, b, denominator), + Rounding::Up => mul_div_ceil(a, b, denominator), + } +} + +/// Quote-currency value (in FIXED_POINT_SCALE-scaled units) of `amount` base +/// units of a token with `decimals`, given `price_scaled` from a price feed. +/// +/// `price_scaled` already carries the FIXED_POINT_SCALE factor (it is the real +/// price multiplied by FIXED_POINT_SCALE, see `PriceFeed::price_scaled`), so the +/// value is `amount * price_scaled / 10^decimals`. +pub fn market_value( + amount: u64, + decimals: u8, + price_scaled: u128, + rounding: Rounding, +) -> Result { + let divisor = ten_pow(decimals as u32)?; + mul_div(amount as u128, price_scaled, divisor, rounding) +} + +/// Inverse of [`market_value`]: how many base units of a token with `decimals` +/// are worth `value_scaled` quote-currency value at `price_scaled`. +pub fn value_to_amount( + value_scaled: u128, + decimals: u8, + price_scaled: u128, + rounding: Rounding, +) -> Result { + let multiplier = ten_pow(decimals as u32)?; + let amount = mul_div(value_scaled, multiplier, price_scaled, rounding)?; + u64::try_from(amount).map_err(|_| LendingError::MathOverflow.into()) +} + +/// Combine a price feed's exponent with the fixed-point scale into a single net +/// power of ten. `price_scaled = real_price * FIXED_POINT_SCALE`, and +/// `real_price = mantissa * 10^exponent`, so +/// `price_scaled = mantissa * 10^(exponent + FIXED_POINT_SCALE_DECIMALS)`. +/// Folding the two powers avoids forming a 10^18 intermediate that would +/// overflow for high-priced assets. +pub fn price_mantissa_to_scaled(mantissa: u128, exponent: i32) -> Result { + let net_exponent = exponent + .checked_add(FIXED_POINT_SCALE_DECIMALS) + .ok_or(LendingError::MathOverflow)?; + if net_exponent >= 0 { + Ok(mantissa + .checked_mul(ten_pow(net_exponent as u32)?) + .ok_or(LendingError::MathOverflow)?) + } else { + Ok(mantissa + .checked_div(ten_pow((-net_exponent) as u32)?) + .ok_or(LendingError::MathOverflow)?) + } +} + +/// Signer seeds for a reserve PDA, which is the authority over its liquidity +/// vault and the mint authority of its share token. +pub fn reserve_signer_seeds<'a>( + lending_market: &'a Pubkey, + liquidity_mint: &'a Pubkey, + bump: &'a [u8; 1], +) -> [&'a [u8]; 4] { + [ + RESERVE_SEED, + lending_market.as_ref(), + liquidity_mint.as_ref(), + bump, + ] +} diff --git a/finance/lending/anchor/programs/lending/src/state/lending_market.rs b/finance/lending/anchor/programs/lending/src/state/lending_market.rs new file mode 100644 index 00000000..d4ee951d --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/state/lending_market.rs @@ -0,0 +1,16 @@ +use anchor_lang::prelude::*; + +/// Top-level configuration shared by every reserve and obligation under it. +/// The owner is the only account that may create reserves and change their config. +#[account] +#[derive(InitSpace)] +pub struct LendingMarket { + pub owner: Pubkey, + + /// The mint that obligation values are denominated in (for example USDC). + /// Stored for reference; valuations come from each reserve's own price feed, + /// which must report prices in this currency. + pub quote_currency_mint: Pubkey, + + pub bump: u8, +} diff --git a/finance/lending/anchor/programs/lending/src/state/mod.rs b/finance/lending/anchor/programs/lending/src/state/mod.rs new file mode 100644 index 00000000..02285562 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/state/mod.rs @@ -0,0 +1,9 @@ +pub mod lending_market; +pub mod obligation; +pub mod price_feed; +pub mod reserve; + +pub use lending_market::*; +pub use obligation::*; +pub use price_feed::*; +pub use reserve::*; diff --git a/finance/lending/anchor/programs/lending/src/state/obligation.rs b/finance/lending/anchor/programs/lending/src/state/obligation.rs new file mode 100644 index 00000000..af161e36 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/state/obligation.rs @@ -0,0 +1,124 @@ +use anchor_lang::prelude::*; + +use crate::constants::MAX_OBLIGATION_RESERVES; +use crate::errors::LendingError; + +/// A borrower's position in one lending market: the share-token collateral they +/// have posted and the liquidity they have borrowed, plus the cached quote- +/// currency valuations that `refresh_obligation` recomputes. +#[account] +#[derive(InitSpace)] +pub struct Obligation { + pub lending_market: Pubkey, + + pub owner: Pubkey, + + pub last_update_slot: u64, + + /// Set whenever deposits/borrows change; cleared by `refresh_obligation`. + /// Health-dependent handlers reject a stale obligation so they never act on + /// cached values that a prior instruction in the same transaction invalidated. + pub stale: bool, + + /// Sum of every deposit's market value, FIXED_POINT_SCALE-scaled. + pub deposited_value: u128, + + /// Sum of every borrow's market value, FIXED_POINT_SCALE-scaled. + pub borrowed_value: u128, + + /// Σ (deposit value * reserve loan_to_value). Borrows may not exceed this. + pub allowed_borrow_value: u128, + + /// Σ (deposit value * reserve liquidation_threshold). Above this the + /// obligation is liquidatable. + pub unhealthy_borrow_value: u128, + + #[max_len(MAX_OBLIGATION_RESERVES)] + pub deposits: Vec, + + #[max_len(MAX_OBLIGATION_RESERVES)] + pub borrows: Vec, + + pub bump: u8, +} + +#[derive(InitSpace, Clone, Copy, AnchorSerialize, AnchorDeserialize, Debug, Default)] +pub struct ObligationCollateral { + pub reserve: Pubkey, + pub deposited_shares: u64, + pub market_value: u128, +} + +#[derive(InitSpace, Clone, Copy, AnchorSerialize, AnchorDeserialize, Debug, Default)] +pub struct ObligationLiquidity { + pub reserve: Pubkey, + /// Borrowed principal, scaled by the reserve's index at borrow time so the + /// live debt grows automatically as that index advances: + /// `debt = borrowed_scaled * reserve.cumulative_borrow_rate_index / FIXED_POINT_SCALE`. + pub borrowed_scaled: u128, + pub market_value: u128, +} + +impl Obligation { + /// Reject a health-dependent action when the obligation has not been + /// refreshed in this same transaction. + pub fn require_refreshed(&self) -> Result<()> { + require!(!self.stale, LendingError::ObligationStale); + require_eq!( + self.last_update_slot, + Clock::get()?.slot, + LendingError::ObligationStale + ); + Ok(()) + } + + /// Index of the collateral entry for `reserve`, creating an empty one if the + /// obligation has room. Used when posting collateral. + pub fn upsert_collateral(&mut self, reserve: Pubkey) -> Result { + if let Some(index) = self.deposits.iter().position(|entry| entry.reserve == reserve) { + return Ok(index); + } + require!( + self.deposits.len() < MAX_OBLIGATION_RESERVES, + LendingError::TooManyReserves + ); + self.deposits.push(ObligationCollateral { + reserve, + deposited_shares: 0, + market_value: 0, + }); + Ok(self.deposits.len() - 1) + } + + /// Index of the borrow entry for `reserve`, creating an empty one if the + /// obligation has room. Used when borrowing. + pub fn upsert_borrow(&mut self, reserve: Pubkey) -> Result { + if let Some(index) = self.borrows.iter().position(|entry| entry.reserve == reserve) { + return Ok(index); + } + require!( + self.borrows.len() < MAX_OBLIGATION_RESERVES, + LendingError::TooManyReserves + ); + self.borrows.push(ObligationLiquidity { + reserve, + borrowed_scaled: 0, + market_value: 0, + }); + Ok(self.borrows.len() - 1) + } + + pub fn find_collateral(&self, reserve: Pubkey) -> Result { + self.deposits + .iter() + .position(|entry| entry.reserve == reserve) + .ok_or(LendingError::ReserveNotFound.into()) + } + + pub fn find_borrow(&self, reserve: Pubkey) -> Result { + self.borrows + .iter() + .position(|entry| entry.reserve == reserve) + .ok_or(LendingError::ReserveNotFound.into()) + } +} diff --git a/finance/lending/anchor/programs/lending/src/state/price_feed.rs b/finance/lending/anchor/programs/lending/src/state/price_feed.rs new file mode 100644 index 00000000..52aa8978 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/state/price_feed.rs @@ -0,0 +1,47 @@ +use anchor_lang::prelude::*; + +use crate::constants::MAX_PRICE_STALENESS_SLOTS; +use crate::errors::LendingError; +use crate::math::price_mantissa_to_scaled; + +/// A price for one token, denominated in the market's quote currency. +/// +/// The layout mirrors a Switchboard On-Demand pull feed: a signed mantissa plus +/// an exponent (`price = price_mantissa * 10^exponent`) and the slot the value +/// was written. In production this account would be the real Switchboard feed +/// and the program would decode it with the `switchboard-on-demand` crate +/// (`PullFeedAccountData`): `price_mantissa = current_result.value`, +/// `exponent = -18`, `last_updated_slot = current_result.slot`. Here the +/// `set_price` handler writes it directly so LiteSVM tests are deterministic. +#[account] +#[derive(InitSpace)] +pub struct PriceFeed { + pub mint: Pubkey, + + pub price_mantissa: i128, + + pub exponent: i32, + + pub last_updated_slot: u64, + + /// Account permitted to call `set_price`. In production this field is unused + /// because the feed is owned by Switchboard, not this program. + pub authority: Pubkey, + + pub bump: u8, +} + +impl PriceFeed { + /// The price multiplied by FIXED_POINT_SCALE, after asserting the feed is + /// fresh and positive. Combining the price exponent with the fixed-point + /// scale (see `price_mantissa_to_scaled`) keeps the conversion overflow-safe. + pub fn price_scaled(&self, current_slot: u64) -> Result { + let age = current_slot + .checked_sub(self.last_updated_slot) + .ok_or(LendingError::MathOverflow)?; + require!(age <= MAX_PRICE_STALENESS_SLOTS, LendingError::StalePriceFeed); + require!(self.price_mantissa > 0, LendingError::InvalidOraclePrice); + + price_mantissa_to_scaled(self.price_mantissa as u128, self.exponent) + } +} diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs new file mode 100644 index 00000000..2880ad23 --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -0,0 +1,209 @@ +use anchor_lang::prelude::*; + +use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, SLOTS_PER_YEAR}; +use crate::errors::LendingError; +use crate::math::{mul_div_ceil, mul_div_floor}; + +/// One asset's lending pool. Suppliers deposit `liquidity_mint` tokens into +/// `liquidity_vault` and receive share tokens (`share_mint`); the share-to- +/// liquidity exchange rate rises as borrowers pay interest. Borrowers draw +/// `liquidity_mint` out against collateral held in their obligation. +#[account] +#[derive(InitSpace)] +pub struct Reserve { + pub lending_market: Pubkey, + + pub liquidity_mint: Pubkey, + + /// Program-owned token account holding the un-borrowed liquidity. Its + /// authority is this reserve PDA. + pub liquidity_vault: Pubkey, + + /// Share-token mint. Supply equals `share_mint_supply`. Mint authority is + /// this reserve PDA. + pub share_mint: Pubkey, + + pub price_feed: Pubkey, + + pub liquidity_decimals: u8, + + /// Base units sitting in `liquidity_vault`, available to borrow or redeem. + /// This is the source of truth for the pool size, not the vault's token + /// balance, so a raw token donation cannot move the exchange rate. + pub available_liquidity: u64, + + /// Outstanding share-token supply, tracked here so valuations need only the + /// reserve account (not the mint) to convert shares to liquidity. + pub share_mint_supply: u64, + + /// Total borrowed principal, scaled so that the live debt is + /// `borrowed_amount_scaled * cumulative_borrow_rate_index / FIXED_POINT_SCALE`. + pub borrowed_amount_scaled: u128, + + /// Monotonically increasing interest index, FIXED_POINT_SCALE-scaled. + /// Starts at FIXED_POINT_SCALE (1.0) and only ever multiplies by factors >= 1. + pub cumulative_borrow_rate_index: u128, + + pub last_update_slot: u64, + + pub config: ReserveConfig, + + pub bump: u8, +} + +/// Risk and interest-rate parameters. All ratios are basis points (10_000 = 100%). +#[derive(InitSpace, Clone, Copy, AnchorSerialize, AnchorDeserialize, Debug, Default)] +pub struct ReserveConfig { + /// Fraction of deposited collateral value a borrower may borrow against. + pub loan_to_value_bps: u16, + /// Above this fraction the obligation may be liquidated. + pub liquidation_threshold_bps: u16, + /// Extra collateral a liquidator receives, as a fraction of the repaid value. + pub liquidation_bonus_bps: u16, + /// Maximum fraction of a borrow that one liquidation may repay. + pub close_factor_bps: u16, + /// Utilization at which the borrow rate reaches `optimal_borrow_rate_bps`. + pub optimal_utilization_bps: u16, + /// Borrow APR at 0% utilization. + pub min_borrow_rate_bps: u16, + /// Borrow APR at `optimal_utilization_bps`. + pub optimal_borrow_rate_bps: u16, + /// Borrow APR at 100% utilization. + pub max_borrow_rate_bps: u16, +} + +impl ReserveConfig { + pub fn validate(&self) -> Result<()> { + let within_bps = |value: u16| (value as u128) <= BPS_DENOMINATOR; + require!( + within_bps(self.loan_to_value_bps) + && within_bps(self.liquidation_threshold_bps) + && within_bps(self.liquidation_bonus_bps) + && within_bps(self.close_factor_bps) + && within_bps(self.optimal_utilization_bps), + LendingError::InvalidConfig + ); + // A zero close factor would make every liquidation a no-op. + require!(self.close_factor_bps > 0, LendingError::InvalidConfig); + // The kink must be strictly inside (0, 100%) so neither rate slope divides by zero. + require!( + self.optimal_utilization_bps > 0 + && (self.optimal_utilization_bps as u128) < BPS_DENOMINATOR, + LendingError::InvalidConfig + ); + // You cannot be allowed to borrow past the point you'd be liquidated. + require!( + self.loan_to_value_bps <= self.liquidation_threshold_bps, + LendingError::InvalidConfig + ); + require!( + self.min_borrow_rate_bps <= self.optimal_borrow_rate_bps + && self.optimal_borrow_rate_bps <= self.max_borrow_rate_bps, + LendingError::InvalidConfig + ); + Ok(()) + } +} + +impl Reserve { + /// Live total debt owed to the pool, rounded up (protocol-favourable). + pub fn current_borrowed_amount(&self) -> Result { + let amount = mul_div_ceil( + self.borrowed_amount_scaled, + self.cumulative_borrow_rate_index, + FIXED_POINT_SCALE, + )?; + u64::try_from(amount).map_err(|_| LendingError::MathOverflow.into()) + } + + /// Available liquidity plus live debt — the pool size the share token is a claim on. + pub fn total_liquidity(&self) -> Result { + Ok((self.available_liquidity as u128) + .checked_add(self.current_borrowed_amount()? as u128) + .ok_or(LendingError::MathOverflow)?) + } + + /// Borrowed fraction of the pool, in basis points (0..=10_000). + pub fn utilization_bps(&self) -> Result { + let total = self.total_liquidity()?; + if total == 0 { + return Ok(0); + } + mul_div_floor(self.current_borrowed_amount()? as u128, BPS_DENOMINATOR, total) + } + + /// Per-slot borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve: + /// linear from `min` to `optimal` up to the kink, then steeper from `optimal` + /// to `max` between the kink and full utilization. + pub fn current_borrow_rate_per_slot(&self) -> Result { + let utilization = self.utilization_bps()?; + let optimal_utilization = self.config.optimal_utilization_bps as u128; + + let apr_bps = if utilization <= optimal_utilization { + let rate_range = (self.config.optimal_borrow_rate_bps as u128) + .checked_sub(self.config.min_borrow_rate_bps as u128) + .ok_or(LendingError::MathOverflow)?; + let climbed = mul_div_floor(rate_range, utilization, optimal_utilization)?; + (self.config.min_borrow_rate_bps as u128) + .checked_add(climbed) + .ok_or(LendingError::MathOverflow)? + } else { + let rate_range = (self.config.max_borrow_rate_bps as u128) + .checked_sub(self.config.optimal_borrow_rate_bps as u128) + .ok_or(LendingError::MathOverflow)?; + let utilization_above = utilization + .checked_sub(optimal_utilization) + .ok_or(LendingError::MathOverflow)?; + let utilization_range = BPS_DENOMINATOR + .checked_sub(optimal_utilization) + .ok_or(LendingError::MathOverflow)?; + let climbed = mul_div_floor(rate_range, utilization_above, utilization_range)?; + (self.config.optimal_borrow_rate_bps as u128) + .checked_add(climbed) + .ok_or(LendingError::MathOverflow)? + }; + + // apr_bps / (BPS_DENOMINATOR * SLOTS_PER_YEAR), carried at FIXED_POINT_SCALE. + let per_year_denominator = BPS_DENOMINATOR + .checked_mul(SLOTS_PER_YEAR) + .ok_or(LendingError::MathOverflow)?; + mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator) + } + + /// Advance the interest index for the slots elapsed since the last refresh. + /// `new_index = old_index * (1 + rate_per_slot * elapsed_slots)`, a single + /// multiply per refresh that compounds across refreshes (Solend's approach). + pub fn accrue_interest(&mut self, current_slot: u64) -> Result<()> { + let elapsed = current_slot + .checked_sub(self.last_update_slot) + .ok_or(LendingError::MathOverflow)?; + + if elapsed > 0 && self.borrowed_amount_scaled > 0 { + let rate_per_slot = self.current_borrow_rate_per_slot()?; + let accrued = rate_per_slot + .checked_mul(elapsed as u128) + .ok_or(LendingError::MathOverflow)?; + let growth_factor = FIXED_POINT_SCALE + .checked_add(accrued) + .ok_or(LendingError::MathOverflow)?; + self.cumulative_borrow_rate_index = mul_div_floor( + self.cumulative_borrow_rate_index, + growth_factor, + FIXED_POINT_SCALE, + )?; + } + + self.last_update_slot = current_slot; + Ok(()) + } + + /// Reject use of a reserve whose interest has not been accrued this slot. + pub fn require_refreshed(&self) -> Result<()> { + require_eq!( + self.last_update_slot, + Clock::get()?.slot, + LendingError::ReserveStale + ); + Ok(()) + } +} diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs new file mode 100644 index 00000000..4204b4a0 --- /dev/null +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -0,0 +1,630 @@ +#![allow(dead_code)] +//! Shared LiteSVM harness for the lending program tests. +//! +//! Sets up a lending market with reserves, funds users, and exposes one method +//! per protocol action. Actions that read value (deposit/redeem/borrow/withdraw/ +//! liquidate) bundle the required `refresh_reserve` / `refresh_obligation` +//! instructions into the same transaction, exactly as a real client must. + +use anchor_lang::{ + solana_program::{ + instruction::{AccountMeta, Instruction}, + system_program, + }, + AccountDeserialize, InstructionData, ToAccountMetas, +}; +use anchor_spl::token::ID as TOKEN_PROGRAM_ID; +use litesvm::LiteSVM; +use solana_keypair::Keypair; +use solana_kite::{ + create_associated_token_account, create_token_mint, create_wallet, get_token_account_balance, + mint_tokens_to_token_account, send_transaction_from_instructions, +}; +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, +}; +use lending::state::{Obligation, Reserve, ReserveConfig}; + +pub use anchor_lang::prelude::Pubkey; + +/// A FIXED_POINT_SCALE-scaled price exponent: prices are passed as +/// `mantissa * 10^-18`, matching a Switchboard On-Demand feed's 1e18 result. +pub const PRICE_EXPONENT: i32 = -18; + +pub fn dollars(whole: u64) -> i128 { + // price mantissa for `whole` dollars at exponent -18. + (whole as i128) * 1_000_000_000_000_000_000 +} + +pub fn cents(amount: u64) -> i128 { + (amount as i128) * 10_000_000_000_000_000 +} + +pub fn ata(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + let ata_program: Pubkey = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + .parse() + .unwrap(); + Pubkey::find_program_address( + &[owner.as_ref(), TOKEN_PROGRAM_ID.as_ref(), mint.as_ref()], + &ata_program, + ) + .0 +} + +fn pda(seeds: &[&[u8]]) -> Pubkey { + Pubkey::find_program_address(seeds, &lending::id()).0 +} + +/// Map kite's transaction result to a String so tests can assert on the program +/// error message embedded in failed-transaction logs. +fn send( + svm: &mut LiteSVM, + instructions: Vec, + signers: &[&Keypair], + payer: &Pubkey, +) -> Result<(), String> { + send_transaction_from_instructions(svm, instructions, signers, payer) + .map_err(|thrown| format!("{thrown:?}")) +} + +/// Handle to one reserve and its associated PDAs. +#[derive(Clone, Copy)] +pub struct ReserveHandle { + pub mint: Pubkey, + pub decimals: u8, + pub reserve: Pubkey, + pub share_mint: Pubkey, + pub liquidity_vault: Pubkey, + pub price_feed: Pubkey, +} + +pub struct Env { + pub svm: LiteSVM, + /// Market owner; also the mint authority for every test mint and the price + /// feed authority. + pub owner: Keypair, + pub market: Pubkey, +} + +impl Env { + pub fn new() -> Self { + let mut svm = LiteSVM::new(); + let program_bytes = include_bytes!("../../../../target/deploy/lending.so"); + svm.add_program(lending::id(), program_bytes).unwrap(); + + let owner = create_wallet(&mut svm, 1_000_000_000_000).unwrap(); + let quote_mint = create_token_mint(&mut svm, &owner, 6, None).unwrap(); + let market = pda(&[LENDING_MARKET_SEED, owner.pubkey().as_ref()]); + + let instruction = Instruction { + program_id: lending::id(), + accounts: lending::accounts::InitLendingMarket { + lending_market: market, + owner: owner.pubkey(), + quote_currency_mint: quote_mint, + system_program: system_program::id(), + } + .to_account_metas(None), + data: lending::instruction::InitLendingMarket {}.data(), + }; + send(&mut svm, vec![instruction], &[&owner], &owner.pubkey()).unwrap(); + + Env { svm, owner, market } + } + + pub fn current_slot(&self) -> u64 { + self.svm.get_sysvar::().slot + } + + /// Advance time so interest accrues and blockhashes differ. + pub fn warp_slots(&mut self, slots: u64) { + let target = self.current_slot() + slots; + self.svm.warp_to_slot(target); + self.svm.expire_blockhash(); + } + + pub fn set_price(&mut self, mint: Pubkey, price_mantissa: i128) { + let price_feed = pda(&[PRICE_FEED_SEED, mint.as_ref()]); + let instruction = Instruction { + program_id: lending::id(), + accounts: lending::accounts::SetPrice { + price_feed, + authority: self.owner.pubkey(), + mint, + system_program: system_program::id(), + } + .to_account_metas(None), + data: lending::instruction::SetPrice { + price_mantissa, + exponent: PRICE_EXPONENT, + } + .data(), + }; + let owner = self.owner.insecure_clone(); + send(&mut self.svm, vec![instruction], &[&owner], &owner.pubkey()).unwrap(); + } + + pub fn add_reserve( + &mut self, + decimals: u8, + price_mantissa: i128, + config: ReserveConfig, + ) -> ReserveHandle { + let owner = self.owner.insecure_clone(); + let mint = create_token_mint(&mut self.svm, &owner, decimals, None).unwrap(); + self.set_price(mint, price_mantissa); + + let reserve = pda(&[RESERVE_SEED, self.market.as_ref(), mint.as_ref()]); + let share_mint = pda(&[SHARE_MINT_SEED, reserve.as_ref()]); + let liquidity_vault = pda(&[LIQUIDITY_VAULT_SEED, reserve.as_ref()]); + let price_feed = pda(&[PRICE_FEED_SEED, mint.as_ref()]); + + let instruction = Instruction { + program_id: lending::id(), + accounts: lending::accounts::InitReserve { + lending_market: self.market, + owner: owner.pubkey(), + reserve, + liquidity_mint: mint, + liquidity_vault, + share_mint, + price_feed, + token_program: TOKEN_PROGRAM_ID, + system_program: system_program::id(), + } + .to_account_metas(None), + data: lending::instruction::InitReserve { config }.data(), + }; + send(&mut self.svm, vec![instruction], &[&owner], &owner.pubkey()).unwrap(); + + ReserveHandle { + mint, + decimals, + reserve, + share_mint, + liquidity_vault, + price_feed, + } + } + + pub fn try_update_config( + &mut self, + handle: &ReserveHandle, + config: ReserveConfig, + ) -> Result<(), String> { + let owner = self.owner.insecure_clone(); + let instruction = Instruction { + program_id: lending::id(), + accounts: lending::accounts::UpdateReserveConfig { + lending_market: self.market, + owner: owner.pubkey(), + reserve: handle.reserve, + } + .to_account_metas(None), + data: lending::instruction::UpdateReserveConfig { config }.data(), + }; + send(&mut self.svm, vec![instruction], &[&owner], &owner.pubkey()) + } + + pub fn create_user(&mut self) -> Keypair { + create_wallet(&mut self.svm, 1_000_000_000_000).unwrap() + } + + /// Create the user's token account for a mint and mint `amount` into it. + pub fn fund(&mut self, user: &Keypair, mint: Pubkey, amount: u64) -> Pubkey { + let owner = self.owner.insecure_clone(); + let token_account = + create_associated_token_account(&mut self.svm, &user.pubkey(), &mint, user).unwrap(); + if amount > 0 { + mint_tokens_to_token_account(&mut self.svm, &mint, &token_account, amount, &owner) + .unwrap(); + } + token_account + } + + fn refresh_reserve_ix(&self, handle: &ReserveHandle) -> Instruction { + Instruction { + program_id: lending::id(), + accounts: lending::accounts::RefreshReserve { + reserve: handle.reserve, + } + .to_account_metas(None), + data: lending::instruction::RefreshReserve {}.data(), + } + } + + /// Supply liquidity to a reserve, receiving share tokens. Returns the user's + /// share-token account. + pub fn try_supply( + &mut self, + user: &Keypair, + handle: &ReserveHandle, + amount: u64, + ) -> Result { + 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 deposit = Instruction { + program_id: lending::id(), + accounts: lending::accounts::DepositReserveLiquidity { + reserve: handle.reserve, + liquidity_mint: handle.mint, + liquidity_vault: handle.liquidity_vault, + share_mint: handle.share_mint, + user_liquidity, + user_share, + owner: user.pubkey(), + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None), + data: lending::instruction::DepositReserveLiquidity { + liquidity_amount: amount, + } + .data(), + }; + let refresh = self.refresh_reserve_ix(handle); + send(&mut self.svm, vec![refresh, deposit], &[user], &user.pubkey())?; + Ok(user_share) + } + + pub fn supply(&mut self, user: &Keypair, handle: &ReserveHandle, amount: u64) -> Pubkey { + self.try_supply(user, handle, amount).unwrap() + } + + pub fn try_redeem( + &mut self, + user: &Keypair, + handle: &ReserveHandle, + share_amount: u64, + ) -> Result<(), String> { + let user_liquidity = ata(&user.pubkey(), &handle.mint); + let user_share = ata(&user.pubkey(), &handle.share_mint); + let redeem = Instruction { + program_id: lending::id(), + accounts: lending::accounts::RedeemReserveCollateral { + reserve: handle.reserve, + liquidity_mint: handle.mint, + liquidity_vault: handle.liquidity_vault, + share_mint: handle.share_mint, + user_liquidity, + user_share, + owner: user.pubkey(), + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None), + data: lending::instruction::RedeemReserveCollateral { share_amount }.data(), + }; + let refresh = self.refresh_reserve_ix(handle); + send(&mut self.svm, vec![refresh, redeem], &[user], &user.pubkey()) + } + + pub fn init_obligation(&mut self, user: &Keypair) -> Pubkey { + let obligation = pda(&[OBLIGATION_SEED, self.market.as_ref(), user.pubkey().as_ref()]); + let instruction = Instruction { + program_id: lending::id(), + accounts: lending::accounts::InitObligation { + lending_market: self.market, + obligation, + owner: user.pubkey(), + system_program: system_program::id(), + } + .to_account_metas(None), + data: lending::instruction::InitObligation {}.data(), + }; + send(&mut self.svm, vec![instruction], &[user], &user.pubkey()).unwrap(); + obligation + } + + pub fn obligation_share_vault(&self, handle: &ReserveHandle, obligation: Pubkey) -> Pubkey { + pda(&[ + OBLIGATION_SHARE_VAULT_SEED, + handle.reserve.as_ref(), + obligation.as_ref(), + ]) + } + + pub fn post_collateral( + &mut self, + user: &Keypair, + obligation: Pubkey, + handle: &ReserveHandle, + share_amount: u64, + ) { + let user_share = ata(&user.pubkey(), &handle.share_mint); + let vault = self.obligation_share_vault(handle, obligation); + let instruction = Instruction { + program_id: lending::id(), + accounts: lending::accounts::DepositObligationCollateral { + obligation, + owner: user.pubkey(), + reserve: handle.reserve, + share_mint: handle.share_mint, + obligation_share_vault: vault, + user_share, + token_program: TOKEN_PROGRAM_ID, + system_program: system_program::id(), + } + .to_account_metas(None), + data: lending::instruction::DepositObligationCollateral { share_amount }.data(), + }; + send(&mut self.svm, vec![instruction], &[user], &user.pubkey()).unwrap(); + } + + fn refresh_obligation_ix( + &self, + obligation: Pubkey, + deposit_reserves: &[&ReserveHandle], + borrow_reserves: &[&ReserveHandle], + ) -> Instruction { + let mut accounts = lending::accounts::RefreshObligation { obligation }.to_account_metas(None); + for handle in deposit_reserves.iter().chain(borrow_reserves.iter()) { + accounts.push(AccountMeta::new_readonly(handle.reserve, false)); + accounts.push(AccountMeta::new_readonly(handle.price_feed, false)); + } + Instruction { + program_id: lending::id(), + accounts, + data: lending::instruction::RefreshObligation {}.data(), + } + } + + /// All reserves an obligation touches must be refreshed before + /// refresh_obligation; this collects the de-duplicated refresh instructions. + fn refresh_all_ix(&self, reserves: &[&ReserveHandle]) -> Vec { + let mut seen: Vec = Vec::new(); + let mut instructions = Vec::new(); + for handle in reserves { + if !seen.contains(&handle.reserve) { + seen.push(handle.reserve); + instructions.push(self.refresh_reserve_ix(handle)); + } + } + instructions + } + + /// `existing_deposits` / `existing_borrows` must list the obligation's + /// CURRENT positions (what `refresh_obligation` will value). The reserve + /// being borrowed is refreshed too, but is only added to `refresh_obligation` + /// once it actually has a borrow entry — so the first borrow of a new reserve + /// passes it only via `borrow`, not via `existing_borrows`. + #[allow(clippy::too_many_arguments)] + pub fn try_borrow( + &mut self, + user: &Keypair, + obligation: Pubkey, + existing_deposits: &[&ReserveHandle], + existing_borrows: &[&ReserveHandle], + borrow: &ReserveHandle, + amount: u64, + ) -> Result<(), String> { + let mut refresh_set: Vec<&ReserveHandle> = existing_deposits.to_vec(); + refresh_set.extend_from_slice(existing_borrows); + refresh_set.push(borrow); + + let mut instructions = self.refresh_all_ix(&refresh_set); + instructions.push(self.refresh_obligation_ix(obligation, existing_deposits, existing_borrows)); + instructions.push(self.borrow_ix(user, obligation, borrow, amount)); + send(&mut self.svm, instructions, &[user], &user.pubkey()) + } + + fn borrow_ix( + &self, + user: &Keypair, + obligation: Pubkey, + borrow: &ReserveHandle, + amount: u64, + ) -> Instruction { + let user_liquidity = ata(&user.pubkey(), &borrow.mint); + Instruction { + program_id: lending::id(), + accounts: lending::accounts::BorrowObligationLiquidity { + obligation, + owner: user.pubkey(), + reserve: borrow.reserve, + price_feed: borrow.price_feed, + liquidity_mint: borrow.mint, + liquidity_vault: borrow.liquidity_vault, + user_liquidity, + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None), + data: lending::instruction::BorrowObligationLiquidity { + liquidity_amount: amount, + } + .data(), + } + } + + /// Borrow while deliberately skipping the `refresh_obligation` instruction, + /// to exercise the `ObligationStale` guard. + pub fn try_borrow_skip_obligation_refresh( + &mut self, + user: &Keypair, + obligation: Pubkey, + all_reserves: &[&ReserveHandle], + borrow: &ReserveHandle, + amount: u64, + ) -> Result<(), String> { + let mut instructions = self.refresh_all_ix(all_reserves); + instructions.push(self.borrow_ix(user, obligation, borrow, amount)); + send(&mut self.svm, instructions, &[user], &user.pubkey()) + } + + pub fn repay( + &mut self, + user: &Keypair, + obligation: Pubkey, + borrow: &ReserveHandle, + amount: u64, + ) { + let user_liquidity = ata(&user.pubkey(), &borrow.mint); + let instructions = vec![ + self.refresh_reserve_ix(borrow), + Instruction { + program_id: lending::id(), + accounts: lending::accounts::RepayObligationLiquidity { + obligation, + reserve: borrow.reserve, + liquidity_mint: borrow.mint, + liquidity_vault: borrow.liquidity_vault, + user_liquidity, + repayer: user.pubkey(), + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None), + data: lending::instruction::RepayObligationLiquidity { + liquidity_amount: amount, + } + .data(), + }, + ]; + send(&mut self.svm, instructions, &[user], &user.pubkey()).unwrap(); + } + + #[allow(clippy::too_many_arguments)] + pub fn try_withdraw_collateral( + &mut self, + user: &Keypair, + obligation: Pubkey, + deposit_reserves: &[&ReserveHandle], + borrow_reserves: &[&ReserveHandle], + collateral: &ReserveHandle, + share_amount: u64, + ) -> Result<(), String> { + let user_share = ata(&user.pubkey(), &collateral.share_mint); + let vault = self.obligation_share_vault(collateral, obligation); + let mut all: Vec<&ReserveHandle> = deposit_reserves.to_vec(); + all.extend_from_slice(borrow_reserves); + + let mut instructions = self.refresh_all_ix(&all); + instructions.push(self.refresh_obligation_ix(obligation, deposit_reserves, borrow_reserves)); + instructions.push(Instruction { + program_id: lending::id(), + accounts: lending::accounts::WithdrawObligationCollateral { + obligation, + owner: user.pubkey(), + reserve: collateral.reserve, + price_feed: collateral.price_feed, + share_mint: collateral.share_mint, + obligation_share_vault: vault, + user_share, + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None), + data: lending::instruction::WithdrawObligationCollateral { share_amount }.data(), + }); + send(&mut self.svm, instructions, &[user], &user.pubkey()) + } + + #[allow(clippy::too_many_arguments)] + pub fn try_liquidate( + &mut self, + liquidator: &Keypair, + obligation: Pubkey, + deposit_reserves: &[&ReserveHandle], + borrow_reserves: &[&ReserveHandle], + repay: &ReserveHandle, + collateral: &ReserveHandle, + amount: u64, + ) -> Result<(), String> { + let repay_source = ata(&liquidator.pubkey(), &repay.mint); + let collateral_dest = create_associated_token_account( + &mut self.svm, + &liquidator.pubkey(), + &collateral.share_mint, + liquidator, + ) + .unwrap(); + let vault = self.obligation_share_vault(collateral, obligation); + + let mut all: Vec<&ReserveHandle> = deposit_reserves.to_vec(); + all.extend_from_slice(borrow_reserves); + let mut instructions = self.refresh_all_ix(&all); + instructions.push(self.refresh_obligation_ix(obligation, deposit_reserves, borrow_reserves)); + instructions.push(Instruction { + program_id: lending::id(), + accounts: lending::accounts::LiquidateObligation { + obligation, + liquidator: liquidator.pubkey(), + repay_reserve: repay.reserve, + collateral_reserve: collateral.reserve, + repay_price_feed: repay.price_feed, + collateral_price_feed: collateral.price_feed, + repay_liquidity_mint: repay.mint, + collateral_share_mint: collateral.share_mint, + repay_liquidity_vault: repay.liquidity_vault, + obligation_collateral_vault: vault, + liquidator_repay_source: repay_source, + liquidator_collateral_dest: collateral_dest, + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None), + data: lending::instruction::LiquidateObligation { + liquidity_amount: amount, + } + .data(), + }); + send(&mut self.svm, instructions, &[liquidator], &liquidator.pubkey()) + } + + /// Send a lone `refresh_reserve` so accrued interest lands in the index. + pub fn refresh_reserve_only(&mut self, payer: &Keypair, handle: &ReserveHandle) { + let instruction = self.refresh_reserve_ix(handle); + send(&mut self.svm, vec![instruction], &[payer], &payer.pubkey()).unwrap(); + } + + /// Refresh the listed reserves and then the obligation, recomputing its values. + pub fn refresh_obligation_only( + &mut self, + payer: &Keypair, + obligation: Pubkey, + deposits: &[&ReserveHandle], + borrows: &[&ReserveHandle], + ) { + let mut all: Vec<&ReserveHandle> = deposits.to_vec(); + all.extend_from_slice(borrows); + let mut instructions = self.refresh_all_ix(&all); + instructions.push(self.refresh_obligation_ix(obligation, deposits, borrows)); + send(&mut self.svm, instructions, &[payer], &payer.pubkey()).unwrap(); + } + + // --- state readers --- + + pub fn reserve(&self, handle: &ReserveHandle) -> Reserve { + let account = self.svm.get_account(&handle.reserve).unwrap(); + Reserve::try_deserialize(&mut account.data.as_slice()).unwrap() + } + + pub fn obligation(&self, obligation: Pubkey) -> Obligation { + let account = self.svm.get_account(&obligation).unwrap(); + Obligation::try_deserialize(&mut account.data.as_slice()).unwrap() + } + + pub fn token_balance(&self, token_account: Pubkey) -> u64 { + get_token_account_balance(&self.svm, &token_account).unwrap() + } +} + +/// A reasonable default reserve config: 75% LTV, 80% liquidation threshold, +/// 5% bonus, 50% close factor, kink at 80% utilization, 2%/20%/150% APR curve. +pub fn default_config() -> ReserveConfig { + ReserveConfig { + loan_to_value_bps: 7_500, + liquidation_threshold_bps: 8_000, + liquidation_bonus_bps: 500, + close_factor_bps: 5_000, + optimal_utilization_bps: 8_000, + min_borrow_rate_bps: 200, + optimal_borrow_rate_bps: 2_000, + max_borrow_rate_bps: 15_000, + } +} diff --git a/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs b/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs new file mode 100644 index 00000000..6e521423 --- /dev/null +++ b/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs @@ -0,0 +1,121 @@ +mod common; + +use common::{ata, default_config, dollars, Env, ReserveHandle}; +use solana_keypair::Keypair; +use solana_signer::Signer; + +/// One market with a collateral reserve and a separately-supplied borrow +/// reserve, plus a borrower who has posted 1000 units of collateral (value +/// $1000, so 75% LTV => $750 borrow power). Both tokens priced at $1, 6 decimals. +fn setup() -> (Env, ReserveHandle, ReserveHandle, Keypair, anchor_lang::prelude::Pubkey) { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + // A different supplier funds the borrow reserve's liquidity. + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); // create the borrowed-token account + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.init_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + + (env, collateral, borrow, borrower, obligation) +} + +#[test] +fn borrow_up_to_max_ltv_then_one_more_fails() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + + // $750 of borrow power, borrowing a $1 token => 750 units exactly. + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 750_000_000) + .unwrap(); + assert_eq!( + env.token_balance(ata(&borrower.pubkey(), &borrow.mint)), + 750_000_000 + ); + + // One more unit exceeds the allowed borrow value. + let result = env.try_borrow(&borrower, obligation, &[&collateral], &[&borrow], &borrow, 1); + assert!( + result.unwrap_err().contains("BorrowTooLarge"), + "borrowing past the LTV limit must be rejected" + ); +} + +#[test] +fn borrow_without_obligation_refresh_is_rejected() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + let result = env.try_borrow_skip_obligation_refresh( + &borrower, + obligation, + &[&collateral, &borrow], + &borrow, + 100_000_000, + ); + assert!(result.unwrap_err().contains("ObligationStale")); +} + +#[test] +fn borrow_with_stale_price_feed_is_rejected() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + // Advance well past the staleness window without re-publishing prices. + env.warp_slots(50); + let result = env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 100_000_000); + assert!(result.unwrap_err().contains("StalePriceFeed")); +} + +#[test] +fn repay_reduces_debt_and_over_repay_clamps() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 500_000_000) + .unwrap(); + assert_eq!(env.reserve(&borrow).borrowed_amount_scaled > 0, true); + + env.repay(&borrower, obligation, &borrow, 200_000_000); + let obligation_state = env.obligation(obligation); + assert_eq!(obligation_state.borrows.len(), 1); + + // Over-repay: ask to repay far more than owed; it clamps to the remaining debt. + env.repay(&borrower, obligation, &borrow, 1_000_000_000); + assert_eq!(env.reserve(&borrow).borrowed_amount_scaled, 0); + assert!(env.obligation(obligation).borrows.is_empty()); +} + +#[test] +fn withdraw_blocked_while_borrowed_then_allowed_after_repay() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 750_000_000) + .unwrap(); + + // At the LTV limit, withdrawing any collateral would undercollateralize. + let blocked = env.try_withdraw_collateral( + &borrower, + obligation, + &[&collateral], + &[&borrow], + &collateral, + 100_000_000, + ); + assert!(blocked.unwrap_err().contains("WithdrawTooLarge")); + + // Repay everything, then the collateral is free to withdraw. + env.repay(&borrower, obligation, &borrow, 750_000_000); + env.try_withdraw_collateral( + &borrower, + obligation, + &[&collateral], + &[], + &collateral, + 1_000_000_000, + ) + .unwrap(); + assert_eq!( + env.token_balance(ata(&borrower.pubkey(), &collateral.share_mint)), + 1_000_000_000 + ); +} diff --git a/finance/lending/anchor/programs/lending/tests/test_deposit_redeem.rs b/finance/lending/anchor/programs/lending/tests/test_deposit_redeem.rs new file mode 100644 index 00000000..f52f3cf8 --- /dev/null +++ b/finance/lending/anchor/programs/lending/tests/test_deposit_redeem.rs @@ -0,0 +1,61 @@ +mod common; + +use common::{default_config, Env}; +use solana_kite::mint_tokens_to_token_account; + +#[test] +fn first_deposit_mints_shares_one_to_one() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let supplier = env.create_user(); + let deposit = 1_000_000_000; // 1000 USDC + env.fund(&supplier, usdc.mint, deposit); + let share_account = env.supply(&supplier, &usdc, deposit); + + assert_eq!(env.token_balance(share_account), deposit); + let reserve = env.reserve(&usdc); + assert_eq!(reserve.available_liquidity, deposit); + assert_eq!(reserve.share_mint_supply, deposit); +} + +#[test] +fn raw_token_donation_does_not_inflate_exchange_rate() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let first = env.create_user(); + let amount = 1_000_000_000; + env.fund(&first, usdc.mint, amount); + env.supply(&first, &usdc, amount); + + // Attacker donates raw tokens straight into the reserve vault. available_liquidity + // is the source of truth, so this must NOT change the share exchange rate. + let owner = env.owner.insecure_clone(); + mint_tokens_to_token_account(&mut env.svm, &usdc.mint, &usdc.liquidity_vault, amount, &owner) + .unwrap(); + + let second = env.create_user(); + env.fund(&second, usdc.mint, amount); + let second_shares = env.supply(&second, &usdc, amount); + + // Despite the donation, the second supplier still gets 1:1 shares. + assert_eq!(env.token_balance(second_shares), amount); +} + +#[test] +fn redeem_returns_underlying_liquidity() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let supplier = env.create_user(); + let amount = 1_000_000_000; + let liquidity_account = env.fund(&supplier, usdc.mint, amount); + let share_account = env.supply(&supplier, &usdc, amount); + assert_eq!(env.token_balance(liquidity_account), 0); + + env.try_redeem(&supplier, &usdc, amount).unwrap(); + assert_eq!(env.token_balance(liquidity_account), amount); + assert_eq!(env.token_balance(share_account), 0); + assert_eq!(env.reserve(&usdc).share_mint_supply, 0); +} diff --git a/finance/lending/anchor/programs/lending/tests/test_interest.rs b/finance/lending/anchor/programs/lending/tests/test_interest.rs new file mode 100644 index 00000000..0fc8ed3c --- /dev/null +++ b/finance/lending/anchor/programs/lending/tests/test_interest.rs @@ -0,0 +1,63 @@ +mod common; + +use common::{default_config, dollars, ata, Env}; +use lending::constants::FIXED_POINT_SCALE; +use solana_signer::Signer; + +/// Borrowing at non-zero utilization, then letting slots pass, must grow the +/// reserve's interest index, the borrower's debt, and the share exchange rate. +#[test] +fn interest_accrues_on_borrows_over_time() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + // Supplier funds 1000 units of borrow liquidity. + let supplier = env.create_user(); + let supplied = 1_000_000_000; + env.fund(&supplier, borrow.mint, supplied); + let supplier_liquidity = ata(&supplier.pubkey(), &borrow.mint); + env.supply(&supplier, &borrow, supplied); + + // Borrower posts collateral and borrows 500 units => 50% utilization. + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.init_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 500_000_000) + .unwrap(); + + assert_eq!(env.reserve(&borrow).cumulative_borrow_rate_index, FIXED_POINT_SCALE); + + // Let ~0.1 year pass (2.5 slots/s => ~7.884M slots), re-publish prices, refresh. + env.warp_slots(7_884_000); + env.set_price(collateral.mint, dollars(1)); + env.set_price(borrow.mint, dollars(1)); + env.refresh_reserve_only(&borrower, &borrow); + + let index_after = env.reserve(&borrow).cumulative_borrow_rate_index; + assert!( + index_after > FIXED_POINT_SCALE, + "interest index must grow once time passes with outstanding borrows" + ); + + // The borrower now owes more than the principal. + env.refresh_obligation_only(&borrower, obligation, &[&collateral], &[&borrow]); + let owed_value = env.obligation(obligation).borrowed_value; + let principal_value = 500u128 * FIXED_POINT_SCALE; // $500 at FIXED_POINT_SCALE per dollar + assert!( + owed_value > principal_value, + "debt value {owed_value} should exceed the $500 principal {principal_value}" + ); + + // The share exchange rate rose: redeeming shares returns more liquidity than + // was deposited per share. Redeem a slice that fits in available liquidity. + env.try_redeem(&supplier, &borrow, 100_000_000).unwrap(); + let returned = env.token_balance(supplier_liquidity); + assert!( + returned > 100_000_000, + "100M shares should redeem for more than 100M liquidity after interest, got {returned}" + ); +} diff --git a/finance/lending/anchor/programs/lending/tests/test_liquidation.rs b/finance/lending/anchor/programs/lending/tests/test_liquidation.rs new file mode 100644 index 00000000..66284d85 --- /dev/null +++ b/finance/lending/anchor/programs/lending/tests/test_liquidation.rs @@ -0,0 +1,100 @@ +mod common; + +use common::{ata, cents, default_config, dollars, Env, ReserveHandle}; +use solana_keypair::Keypair; +use solana_signer::Signer; + +/// A borrower with $1000 of collateral who has borrowed $700 (healthy at 80% +/// liquidation threshold), plus a liquidator funded with the borrow token. +fn setup() -> ( + Env, + ReserveHandle, + ReserveHandle, + Keypair, + anchor_lang::prelude::Pubkey, + Keypair, +) { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.init_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 700_000_000) + .unwrap(); + + let liquidator = env.create_user(); + env.fund(&liquidator, borrow.mint, 1_000_000_000); + + (env, collateral, borrow, borrower, obligation, liquidator) +} + +#[test] +fn healthy_obligation_cannot_be_liquidated() { + let (mut env, collateral, borrow, _borrower, obligation, liquidator) = setup(); + let result = env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 100_000_000, + ); + assert!(result.unwrap_err().contains("ObligationHealthy")); +} + +#[test] +fn unhealthy_obligation_liquidated_with_bonus_capped_by_close_factor() { + let (mut env, collateral, borrow, _borrower, obligation, liquidator) = setup(); + + // Collateral price falls to $0.80: collateral value $800, liquidation + // threshold 80% => $640, while debt is $700 => liquidatable. + env.set_price(collateral.mint, cents(80)); + + let liquidator_repay_account = ata(&liquidator.pubkey(), &borrow.mint); + let liquidator_collateral_account = ata(&liquidator.pubkey(), &collateral.share_mint); + let vault_before = env.reserve(&borrow).available_liquidity; + + // Offer to repay far more than the close factor allows; it caps at 50% of the + // $700 debt = $350. + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 1_000_000_000, + ) + .unwrap(); + + // Exactly $350 (350M base units) was repaid — close-factor cap, not the full offer. + assert_eq!( + env.token_balance(liquidator_repay_account), + 1_000_000_000 - 350_000_000 + ); + assert_eq!( + env.reserve(&borrow).available_liquidity, + vault_before + 350_000_000 + ); + + // Liquidator seized collateral shares worth repay + 5% bonus, priced at $0.80: + // (350 * 1.05) / 0.80 = 459.375 collateral units => 459_375_000 shares (1:1 here). + assert_eq!( + env.token_balance(liquidator_collateral_account), + 459_375_000 + ); + + // The borrower's debt and collateral both dropped. + let obligation_state = env.obligation(obligation); + assert_eq!(obligation_state.deposits[0].deposited_shares, 1_000_000_000 - 459_375_000); +} diff --git a/finance/lending/anchor/programs/lending/tests/test_reserve.rs b/finance/lending/anchor/programs/lending/tests/test_reserve.rs new file mode 100644 index 00000000..4af509ee --- /dev/null +++ b/finance/lending/anchor/programs/lending/tests/test_reserve.rs @@ -0,0 +1,59 @@ +mod common; + +use common::{default_config, Env}; +use lending::constants::FIXED_POINT_SCALE; + +#[test] +fn init_market_and_reserve() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let reserve = env.reserve(&usdc); + assert_eq!(reserve.lending_market, env.market); + assert_eq!(reserve.liquidity_mint, usdc.mint); + assert_eq!(reserve.liquidity_decimals, 6); + assert_eq!(reserve.available_liquidity, 0); + assert_eq!(reserve.share_mint_supply, 0); + assert_eq!(reserve.borrowed_amount_scaled, 0); + // The interest index starts at 1.0. + assert_eq!(reserve.cumulative_borrow_rate_index, FIXED_POINT_SCALE); +} + +#[test] +fn rejects_ltv_above_liquidation_threshold() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let mut bad = default_config(); + bad.loan_to_value_bps = 9_000; + bad.liquidation_threshold_bps = 8_000; + let result = env.try_update_config(&usdc, bad); + assert!( + result.unwrap_err().contains("InvalidConfig"), + "LTV above the liquidation threshold must be rejected" + ); +} + +#[test] +fn rejects_misordered_interest_rate_curve() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let mut bad = default_config(); + bad.min_borrow_rate_bps = 5_000; + bad.optimal_borrow_rate_bps = 2_000; // optimal below min + bad.max_borrow_rate_bps = 15_000; + let result = env.try_update_config(&usdc, bad); + assert!(result.unwrap_err().contains("InvalidConfig")); +} + +#[test] +fn accepts_valid_config_update() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let mut updated = default_config(); + updated.loan_to_value_bps = 6_000; + env.try_update_config(&usdc, updated).unwrap(); + assert_eq!(env.reserve(&usdc).config.loan_to_value_bps, 6_000); +} diff --git a/finance/lending/anchor/programs/lending/tests/test_rounding.rs b/finance/lending/anchor/programs/lending/tests/test_rounding.rs new file mode 100644 index 00000000..054f2966 --- /dev/null +++ b/finance/lending/anchor/programs/lending/tests/test_rounding.rs @@ -0,0 +1,106 @@ +mod common; + +use common::{ata, default_config, dollars, Env}; +use solana_signer::Signer; + +/// After interest makes the pool worth more than its share supply, a deposit so +/// small it would mint zero shares is rejected rather than silently giving the +/// depositor nothing. +#[test] +fn deposit_that_would_mint_zero_shares_is_rejected() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.init_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 500_000_000) + .unwrap(); + + // Accrue enough interest that total liquidity exceeds the share supply. + env.warp_slots(7_884_000); + env.refresh_reserve_only(&borrower, &borrow); + assert!(env.reserve(&borrow).cumulative_borrow_rate_index > lending::constants::FIXED_POINT_SCALE); + + let dust_depositor = env.create_user(); + env.fund(&dust_depositor, borrow.mint, 1); + let result = env.try_supply(&dust_depositor, &borrow, 1); + assert!( + result.unwrap_err().contains("DepositTooSmall"), + "a 1-unit deposit into an appreciated pool mints zero shares and must be rejected" + ); +} + +#[test] +fn deposit_redeem_round_trip_creates_no_value() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, dollars(1), default_config()); + + let user = env.create_user(); + let amount = 777_777_777; + let liquidity_account = env.fund(&user, usdc.mint, amount); + let share_account = env.supply(&user, &usdc, amount); + + let shares = env.token_balance(share_account); + env.try_redeem(&user, &usdc, shares).unwrap(); + + // The round trip must never return more than was put in. + assert!(env.token_balance(liquidity_account) <= amount); +} + +#[test] +fn withdraw_at_health_boundary_then_one_more_unit_fails() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.init_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + + // Borrow $600 against $1000 collateral (75% LTV => $750 power). + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 600_000_000) + .unwrap(); + + // Withdrawing $200 of collateral lands exactly on the limit: new power + // $750 - 0.75*$200 = $600 == debt. This must pass. + env.try_withdraw_collateral( + &borrower, + obligation, + &[&collateral], + &[&borrow], + &collateral, + 200_000_000, + ) + .unwrap(); + assert_eq!( + env.token_balance(ata(&borrower.pubkey(), &collateral.share_mint)), + 200_000_000 + ); + + // One more unit now pushes the obligation past its limit. + let result = env.try_withdraw_collateral( + &borrower, + obligation, + &[&collateral], + &[&borrow], + &collateral, + 1, + ); + assert!(result.unwrap_err().contains("WithdrawTooLarge")); +} From 8305ca9dcd3a396f79e49ea7d376a4f0d205c778 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 22:18:00 +0000 Subject: [PATCH 2/6] Fix anchor build: drop #[constant] from u128 constants `anchor build`'s IDL generation compiles a generated test under the idl-build feature, where the `#[constant]` macro mis-evaluates the 1e18 u128 literal as i32 ("literal out of range for i32"), failing CI. These values don't need to be in the IDL, so they're plain `pub const`s now. https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9 --- finance/lending/anchor/programs/lending/src/constants.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/finance/lending/anchor/programs/lending/src/constants.rs b/finance/lending/anchor/programs/lending/src/constants.rs index 4ff0db05..3affc32e 100644 --- a/finance/lending/anchor/programs/lending/src/constants.rs +++ b/finance/lending/anchor/programs/lending/src/constants.rs @@ -1,4 +1,7 @@ -use anchor_lang::prelude::*; +// These are plain `pub const`s rather than Anchor `#[constant]`s: `#[constant]` +// only re-exports a value into the IDL, and anchor's idl-build mis-evaluates a +// u128 literal this large as i32 ("literal out of range for i32"). None of these +// need to appear in the IDL, so plain consts both compile and keep the IDL clean. /// Fixed-point scale for every ratio in the program: interest rates, the /// cumulative borrow-rate index, the share-token exchange rate, and obligation @@ -8,7 +11,6 @@ use anchor_lang::prelude::*; /// keeps a single slot's interest — which can be a tiny fraction of the index — /// from truncating to zero, while u128's ~3.4e38 ceiling leaves headroom for the /// index to grow and for intermediate products before the final narrowing cast. -#[constant] pub const FIXED_POINT_SCALE: u128 = 1_000_000_000_000_000_000; /// log10(FIXED_POINT_SCALE). Used to fold the price exponent and the fixed-point @@ -17,12 +19,10 @@ pub const FIXED_POINT_SCALE: u128 = 1_000_000_000_000_000_000; pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18; /// Denominator for every basis-point config value. 100% == 10_000 bps. -#[constant] pub const BPS_DENOMINATOR: u128 = 10_000; /// Slots per year, for turning an APR (in bps) into a per-slot rate. /// Solana targets ~2.5 slots/second: 2.5 * 60 * 60 * 24 * 365 = 78_840_000. -#[constant] pub const SLOTS_PER_YEAR: u128 = 78_840_000; /// Maximum distinct reserves an obligation may use as collateral, and @@ -33,7 +33,6 @@ pub const MAX_OBLIGATION_RESERVES: usize = 4; /// A price feed older than this many slots is rejected as stale (~10s at 2.5 /// slots/second). Freshness is measured in slots, not unix time, because the /// runtime guarantees slot progression while the timestamp is validator-influenced. -#[constant] pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25; // PDA seeds. From adb4977c611eaaa19c86b206f1ac0f0e4592571d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 00:02:14 +0000 Subject: [PATCH 3/6] Add Quasar version of the lending program A Quasar (zero-copy, no_std) port of finance/lending, mirroring the shipped Quasar escrow/vault examples' fixed-size-account idiom: - Isolated single-collateral / single-borrow obligations (fixed-size accounts) rather than the Anchor version's Vec-based multi-asset obligation. Quasar does support Vec and CtxWithRemaining, but the DeFi examples favour fixed-size positions, so this follows that idiom. - Interest accrues inline per instruction instead of via a separate refresh. - Keeps every core technique: share-token deposits, a kinked-curve cumulative interest index, oracle-priced health, and close-factor liquidation with a bonus. - Integer-only u128 math scaled by 1e18, rounding in the protocol's favour. quasar-svm tests cover supply/redeem, borrow up to the LTV limit (and rejection beyond), repay, interest accrual lifting share value, and liquidation of an unhealthy position with the healthy path rejected. https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9 --- finance/lending/quasar/CHANGELOG.md | 16 + finance/lending/quasar/Cargo.toml | 35 ++ finance/lending/quasar/Quasar.toml | 22 + finance/lending/quasar/README.md | 75 +++ finance/lending/quasar/src/constants.rs | 34 ++ finance/lending/quasar/src/error.rs | 18 + .../lending/quasar/src/instructions/admin.rs | 211 +++++++ .../lending/quasar/src/instructions/mod.rs | 7 + .../quasar/src/instructions/position.rs | 562 ++++++++++++++++++ .../lending/quasar/src/instructions/supply.rs | 177 ++++++ finance/lending/quasar/src/lib.rs | 136 +++++ finance/lending/quasar/src/logic.rs | 90 +++ finance/lending/quasar/src/math.rs | 223 +++++++ finance/lending/quasar/src/state.rs | 85 +++ finance/lending/quasar/src/tests.rs | 487 +++++++++++++++ 15 files changed, 2178 insertions(+) create mode 100644 finance/lending/quasar/CHANGELOG.md create mode 100644 finance/lending/quasar/Cargo.toml create mode 100644 finance/lending/quasar/Quasar.toml create mode 100644 finance/lending/quasar/README.md create mode 100644 finance/lending/quasar/src/constants.rs create mode 100644 finance/lending/quasar/src/error.rs create mode 100644 finance/lending/quasar/src/instructions/admin.rs create mode 100644 finance/lending/quasar/src/instructions/mod.rs create mode 100644 finance/lending/quasar/src/instructions/position.rs create mode 100644 finance/lending/quasar/src/instructions/supply.rs create mode 100644 finance/lending/quasar/src/lib.rs create mode 100644 finance/lending/quasar/src/logic.rs create mode 100644 finance/lending/quasar/src/math.rs create mode 100644 finance/lending/quasar/src/state.rs create mode 100644 finance/lending/quasar/src/tests.rs diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md new file mode 100644 index 00000000..1e804dad --- /dev/null +++ b/finance/lending/quasar/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 0.1.0 + +Initial Quasar port of the Kamino/Solend-style borrow/lend program. + +- Lending market, per-asset reserves with a program-owned liquidity vault and a + share-token mint, and isolated single-collateral / single-borrow obligations. +- Share-token deposit accounting with an exchange rate driven by accrued interest. +- Utilization-based kinked interest-rate curve compounded through a cumulative + borrow-rate index, accrued inline per instruction. +- Oracle-priced health with loan-to-value and liquidation-threshold limits, and + close-factor-capped liquidation with a seize bonus. +- Switchboard-On-Demand-shaped price feed with a `set_price` test writer. +- quasar-svm integration tests covering supply/redeem, borrow/repay, interest + accrual, and liquidation (including the healthy-rejection path). diff --git a/finance/lending/quasar/Cargo.toml b/finance/lending/quasar/Cargo.toml new file mode 100644 index 00000000..d935ad46 --- /dev/null +++ b/finance/lending/quasar/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "quasar-lending" +version = "0.1.0" +edition = "2021" + +# Standalone workspace — Quasar uses a different resolver and dependency tree +# from the root program-examples workspace. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', +] + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +alloc = [] +client = [] +debug = [] + +[dependencies] +# Pinned to rev 623bb70 for the same reason as the other Quasar examples: master +# HEAD fails to compile because zeropod 0.3.x generates accessor methods that +# conflict with hand-written ones in quasar-spl. Unpin once upstream merges the fix. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +solana-instruction = { version = "3.2.0" } + +[dev-dependencies] +quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } +spl-token-interface = { version = "2.0.0" } +solana-program-pack = { version = "3.1.0" } diff --git a/finance/lending/quasar/Quasar.toml b/finance/lending/quasar/Quasar.toml new file mode 100644 index 00000000..c3e8d514 --- /dev/null +++ b/finance/lending/quasar/Quasar.toml @@ -0,0 +1,22 @@ +[project] +name = "quasar_lending" + +[toolchain] +type = "solana" + +[testing] +language = "rust" + +[testing.rust] +framework = "quasar-svm" + +[testing.rust.test] +program = "cargo" +args = [ + "test", + "tests::", +] + +[clients] +path = "target/client" +languages = ["rust"] diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md new file mode 100644 index 00000000..a8adeb03 --- /dev/null +++ b/finance/lending/quasar/README.md @@ -0,0 +1,75 @@ +# Lending (Quasar) + +A Kamino/Solend-style borrow/lend program written with [Quasar](https://quasar-lang.com), +a zero-copy, `no_std` Solana framework. It is the Quasar counterpart to the Anchor +version in [`../anchor`](../anchor) and keeps the same core techniques: share-token +deposits, a kinked-curve interest index, oracle-priced obligation health, and +close-factor liquidation with a bonus. + +## What's different from the Anchor version + +Quasar accounts are fixed-size and zero-copy. Quasar *does* support bounded +collections (`Vec` / `PodVec`) and remaining accounts (`CtxWithRemaining`) — +the `multisig` example uses both — so a multi-asset obligation is expressible. But +the shipped Quasar DeFi examples (`escrow`, `vault`) model one position with +fixed-size accounts, so this port follows that idiom: + +- **Isolated single-pair positions.** Each `Obligation` holds exactly one + collateral reserve and one borrow reserve (fixed fields), instead of the Anchor + version's `Vec`-based multi-asset obligation. This is the "isolated market" + shape and removes the need for `Vec` elements and variable-account + refreshes. +- **Inline interest accrual.** There is no separate `refresh_reserve` / + `refresh_obligation` step: each value-dependent handler accrues the reserves it + touches at the top of the instruction. Health is then computed inline from the + freshly accrued reserves and the oracle prices passed in. + +Everything else mirrors the Anchor version. + +## Major concepts + +- **`LendingMarket`** — market config (owner, quote-currency mint). PDA: + `["lending_market", owner]`. +- **`Reserve`** — one asset's pool. Owns a program-controlled liquidity vault and + a share-token mint (both PDAs, authority = the reserve), and stores the + interest-rate config, the cumulative borrow-rate index, available liquidity, and + scaled total debt. PDA: `["reserve", market, liquidity_mint]`. +- **`Obligation`** — a borrower's isolated position: the collateral reserve and + deposited share amount, plus the borrow reserve and scaled debt. PDA: + `["obligation", market, owner]`. +- **`PriceFeed`** — a Switchboard-On-Demand-shaped price (`mantissa * 10^exponent` + + slot). `set_price` writes it directly for deterministic tests; in production a + reserve points at the real Switchboard feed. Freshness is checked in slots. +- **Share tokens** — supplying mints them, redeeming burns them; the exchange rate + `total_liquidity / share_supply` rises as borrowers pay interest. + `available_liquidity` (not the vault's raw balance) is the source of truth, so a + token donation can't inflate the rate. +- **Integer-only math** — `u128`, scaled by `FIXED_POINT_SCALE` (10^18), every + conversion rounding in the protocol's favour. + +### Instruction handlers (numeric discriminators) + +`init_lending_market` (0), `init_reserve` (1), `set_price` (2), +`deposit_reserve_liquidity` (3), `redeem_reserve_collateral` (4), +`init_obligation` (5), `deposit_obligation_collateral` (6), +`withdraw_obligation_collateral` (7), `borrow_obligation_liquidity` (8), +`repay_obligation_liquidity` (9), `liquidate_obligation` (10). + +## Setup + +- Rust and the Solana toolchain (`cargo-build-sbf`). +- Quasar (`quasar-lang` / `quasar-spl`), pinned to the rev used across the repo's + Quasar examples (see `Cargo.toml` for the rationale). + +## Testing + +```sh +cargo build-sbf # produces target/deploy/quasar_lending.so +cargo test tests:: # runs the quasar-svm integration tests +``` + +`cargo build-sbf` must run first: the tests load the compiled +`target/deploy/quasar_lending.so` into `quasar-svm`. The suite drives the full +lifecycle — supply/redeem (1:1 first deposit), borrow up to the LTV limit (and +rejection beyond it), repay, interest accrual lifting the share value after slots +pass, and liquidation of an unhealthy position (with a healthy position rejected). diff --git a/finance/lending/quasar/src/constants.rs b/finance/lending/quasar/src/constants.rs new file mode 100644 index 00000000..67d37b8f --- /dev/null +++ b/finance/lending/quasar/src/constants.rs @@ -0,0 +1,34 @@ +//! Shared constants for the Quasar lending program. + +/// Fixed-point scale (10^18) for every ratio: interest rates, the cumulative +/// borrow-rate index, the share-token exchange rate, and obligation values. +/// All money math is integer-only `u128`; a ratio `r` is stored as +/// `r * FIXED_POINT_SCALE`. +pub const FIXED_POINT_SCALE: u128 = 1_000_000_000_000_000_000; + +/// log10(FIXED_POINT_SCALE). Folds the price exponent and the fixed-point scale +/// into one power of ten so price conversions never form a needless 10^18 +/// intermediate that would overflow for high-priced assets. +pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18; + +/// 100% expressed in basis points. +pub const BPS_DENOMINATOR: u128 = 10_000; + +/// Slots per year (~2.5 slots/s), for turning an APR in bps into a per-slot rate. +pub const SLOTS_PER_YEAR: u128 = 78_840_000; + +/// Reject a price feed older than this many slots (~10s at 2.5 slots/s). +pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25; + +/// SPL token account size, for the rent-exempt vault created in `init_reserve`. +pub const TOKEN_ACCOUNT_SPACE: u64 = 165; + +/// SPL mint size, for the rent-exempt share mint created in `init_reserve`. +pub const MINT_SPACE: u64 = 82; + +// PDA seeds for the `Seed::from(...)` signer arrays in the CPI-signing handlers. +// (The `#[seeds(...)]` attributes on the account types carry their own literals.) +pub const RESERVE_SEED: &[u8] = b"reserve"; +pub const LIQUIDITY_VAULT_SEED: &[u8] = b"liquidity_vault"; +pub const SHARE_MINT_SEED: &[u8] = b"share_mint"; +pub const OBLIGATION_SEED: &[u8] = b"obligation"; diff --git a/finance/lending/quasar/src/error.rs b/finance/lending/quasar/src/error.rs new file mode 100644 index 00000000..45a11189 --- /dev/null +++ b/finance/lending/quasar/src/error.rs @@ -0,0 +1,18 @@ +use quasar_lang::prelude::*; + +/// Program errors. `#[error_code]` assigns codes starting at 6000 and generates +/// the `From for ProgramError` conversion that `?` and `require!` use. +#[error_code] +pub enum LendingError { + MathOverflow = 6000, + InvalidConfig, + ZeroAmount, + DepositTooSmall, + InsufficientLiquidity, + StalePrice, + InvalidOraclePrice, + BorrowTooLarge, + WithdrawTooLarge, + ObligationHealthy, + WrongReserve, +} diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs new file mode 100644 index 00000000..b8f41cf5 --- /dev/null +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -0,0 +1,211 @@ +use { + crate::{ + constants::{MINT_SPACE, TOKEN_ACCOUNT_SPACE}, + error::LendingError, + logic::now, + math::validate_config, + state::{ + LendingMarket, LendingMarketInner, LiquidityVaultPda, PriceFeed, PriceFeedInner, + Reserve, ReserveInner, ShareMintPda, + }, + }, + quasar_lang::{prelude::*, sysvars::Sysvar}, + quasar_spl::{initialize_account3, initialize_mint2, prelude::*}, +}; + +// --------------------------------------------------------------------------- +// init_lending_market +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct InitLendingMarket { + #[account(mut)] + pub owner: Signer, + #[account(init, payer = owner, address = LendingMarket::seeds(owner.address()))] + pub lending_market: Account, + pub quote_mint: Account, + pub system_program: Program, +} + +impl InitLendingMarket { + #[inline(always)] + pub fn run(&mut self, bumps: &InitLendingMarketBumps) -> Result<(), ProgramError> { + self.lending_market.set_inner(LendingMarketInner { + owner: *self.owner.address(), + quote_mint: *self.quote_mint.address(), + bump: bumps.lending_market, + }); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// init_reserve +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct InitReserve { + #[account(mut)] + pub owner: Signer, + #[account(has_one(owner), address = LendingMarket::seeds(owner.address()))] + pub lending_market: Account, + #[account(init, payer = owner, address = Reserve::seeds(lending_market.address(), liquidity_mint.address()))] + pub reserve: Account, + pub liquidity_mint: Account, + /// Created and initialized as a token account (authority = reserve) in the handler. + #[account(mut, address = LiquidityVaultPda::seeds(reserve.address()))] + pub liquidity_vault: UncheckedAccount, + /// Created and initialized as a share-token mint (authority = reserve) in the handler. + #[account(mut, address = ShareMintPda::seeds(reserve.address()))] + pub share_mint: UncheckedAccount, + #[account(address = PriceFeed::seeds(liquidity_mint.address()))] + pub price_feed: Account, + pub token_program: Program, + pub system_program: Program, +} + +impl InitReserve { + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub fn run( + &mut self, + loan_to_value_bps: u16, + liquidation_threshold_bps: u16, + liquidation_bonus_bps: u16, + close_factor_bps: u16, + optimal_utilization_bps: u16, + min_borrow_rate_bps: u16, + optimal_borrow_rate_bps: u16, + max_borrow_rate_bps: u16, + bumps: &InitReserveBumps, + ) -> Result<(), ProgramError> { + validate_config( + loan_to_value_bps, + liquidation_threshold_bps, + liquidation_bonus_bps, + close_factor_bps, + optimal_utilization_bps, + min_borrow_rate_bps, + optimal_borrow_rate_bps, + max_borrow_rate_bps, + )?; + + let reserve_address = *self.reserve.address(); + let decimals = self.liquidity_mint.decimals; + let rent = Rent::get()?; + + // Create the program-owned liquidity vault PDA, then initialize it as a + // token account whose authority is the reserve PDA. + let vault_bump = [bumps.liquidity_vault]; + let vault_seeds = [ + Seed::from(crate::constants::LIQUIDITY_VAULT_SEED), + Seed::from(reserve_address.as_ref()), + Seed::from(vault_bump.as_ref()), + ]; + self.system_program + .create_account( + &self.owner, + &self.liquidity_vault, + rent.minimum_balance_unchecked(TOKEN_ACCOUNT_SPACE as usize), + TOKEN_ACCOUNT_SPACE, + self.token_program.address(), + ) + .invoke_signed(&vault_seeds)?; + initialize_account3( + self.token_program.to_account_view(), + self.liquidity_vault.to_account_view(), + self.liquidity_mint.to_account_view(), + &reserve_address, + ) + .invoke()?; + + // Create the share-token mint PDA (authority = reserve, same decimals). + let mint_bump = [bumps.share_mint]; + let mint_seeds = [ + Seed::from(crate::constants::SHARE_MINT_SEED), + Seed::from(reserve_address.as_ref()), + Seed::from(mint_bump.as_ref()), + ]; + self.system_program + .create_account( + &self.owner, + &self.share_mint, + rent.minimum_balance_unchecked(MINT_SPACE as usize), + MINT_SPACE, + self.token_program.address(), + ) + .invoke_signed(&mint_seeds)?; + initialize_mint2( + self.token_program.to_account_view(), + self.share_mint.to_account_view(), + decimals, + &reserve_address, + None, + ) + .invoke()?; + + self.reserve.set_inner(ReserveInner { + lending_market: *self.lending_market.address(), + liquidity_mint: *self.liquidity_mint.address(), + liquidity_vault: *self.liquidity_vault.address(), + share_mint: *self.share_mint.address(), + price_feed: *self.price_feed.address(), + available_liquidity: 0, + share_mint_supply: 0, + borrowed_amount_scaled: 0, + cumulative_borrow_rate_index: crate::constants::FIXED_POINT_SCALE, + last_update_slot: now()?, + liquidity_decimals: decimals, + loan_to_value_bps, + liquidation_threshold_bps, + liquidation_bonus_bps, + close_factor_bps, + optimal_utilization_bps, + min_borrow_rate_bps, + optimal_borrow_rate_bps, + max_borrow_rate_bps, + bump: bumps.reserve, + }); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// set_price (Switchboard stand-in for tests) +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct SetPrice { + #[account(mut)] + pub authority: Signer, + #[account(init(idempotent), payer = authority, address = PriceFeed::seeds(mint.address()))] + pub price_feed: Account, + pub mint: Account, + pub system_program: Program, +} + +impl SetPrice { + #[inline(always)] + pub fn run( + &mut self, + price_mantissa: i128, + exponent: i32, + bumps: &SetPriceBumps, + ) -> Result<(), ProgramError> { + // On first creation the stored authority is the zero address; claim it. + // Afterwards only that authority may update the feed. + let existing = self.price_feed.authority; + if existing != Address::default() { + require_keys_eq!(existing, *self.authority.address(), LendingError::InvalidConfig); + } + self.price_feed.set_inner(PriceFeedInner { + mint: *self.mint.address(), + price_mantissa, + exponent, + last_updated_slot: now()?, + authority: *self.authority.address(), + bump: bumps.price_feed, + }); + Ok(()) + } +} diff --git a/finance/lending/quasar/src/instructions/mod.rs b/finance/lending/quasar/src/instructions/mod.rs new file mode 100644 index 00000000..d4c65b96 --- /dev/null +++ b/finance/lending/quasar/src/instructions/mod.rs @@ -0,0 +1,7 @@ +pub mod admin; +pub mod position; +pub mod supply; + +pub use admin::*; +pub use position::*; +pub use supply::*; diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs new file mode 100644 index 00000000..008b4a81 --- /dev/null +++ b/finance/lending/quasar/src/instructions/position.rs @@ -0,0 +1,562 @@ +use { + crate::{ + constants::BPS_DENOMINATOR, + error::LendingError, + instructions::supply::reserve_seeds, + logic::{accrue, now, price_scaled, snapshot_obligation, snapshot_reserve, SCALE}, + math::{current_debt, market_value, mul_div_ceil, mul_div_floor, total_liquidity, value_to_amount, Rounding}, + state::{ + LendingMarket, Obligation, ObligationInner, ObligationVaultPda, PriceFeed, Reserve, + }, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +/// Obligation PDA signer seeds, used to authorize transfers out of the +/// obligation's collateral vault. +macro_rules! obligation_seeds { + ($lending_market:expr, $owner:expr, $bump:expr) => { + [ + Seed::from(crate::constants::OBLIGATION_SEED), + Seed::from($lending_market.as_ref()), + Seed::from($owner.as_ref()), + Seed::from($bump.as_ref()), + ] + }; +} + +// --------------------------------------------------------------------------- +// init_obligation +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct InitObligation { + #[account(mut)] + pub owner: Signer, + pub lending_market: Account, + #[account(init, payer = owner, address = Obligation::seeds(lending_market.address(), owner.address()))] + pub obligation: Account, + pub system_program: Program, +} + +impl InitObligation { + #[inline(always)] + pub fn run(&mut self, bumps: &InitObligationBumps) -> Result<(), ProgramError> { + self.obligation.set_inner(ObligationInner { + lending_market: *self.lending_market.address(), + owner: *self.owner.address(), + collateral_reserve: Address::default(), + deposited_shares: 0, + borrow_reserve: Address::default(), + borrowed_scaled: 0, + bump: bumps.obligation, + }); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// deposit_obligation_collateral +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct DepositObligationCollateral { + #[account(mut)] + pub owner: Signer, + pub lending_market: Account, + #[account(mut, has_one(owner), has_one(lending_market), address = Obligation::seeds(lending_market.address(), owner.address()))] + pub obligation: Account, + #[account(has_one(share_mint))] + pub reserve: Account, + pub share_mint: Account, + #[account( + init(idempotent), + payer = owner, + token(mint = share_mint, authority = obligation, token_program = token_program), + address = ObligationVaultPda::seeds(reserve.address(), obligation.address()) + )] + pub obligation_vault: InterfaceAccount, + #[account(mut)] + pub owner_share: Account, + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, +} + +impl DepositObligationCollateral { + #[inline(always)] + pub fn run(&mut self, shares: u64) -> Result<(), ProgramError> { + require!(shares > 0, LendingError::ZeroAmount); + let reserve_address = *self.reserve.address(); + + let mut obligation = snapshot_obligation(&self.obligation); + if obligation.collateral_reserve == Address::default() { + obligation.collateral_reserve = reserve_address; + } else { + require_keys_eq!(obligation.collateral_reserve, reserve_address, LendingError::WrongReserve); + } + obligation.deposited_shares = obligation + .deposited_shares + .checked_add(shares) + .ok_or(LendingError::MathOverflow)?; + let decimals = self.share_mint.decimals; + self.obligation.set_inner(obligation); + + self.token_program + .transfer_checked( + &self.owner_share, + &self.share_mint, + &self.obligation_vault, + &self.owner, + shares, + decimals, + ) + .invoke() + } +} + +// --------------------------------------------------------------------------- +// borrow_obligation_liquidity +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct BorrowObligationLiquidity { + #[account(mut)] + pub owner: Signer, + pub lending_market: Account, + #[account(mut, has_one(owner), has_one(lending_market), address = Obligation::seeds(lending_market.address(), owner.address()))] + pub obligation: Account, + #[account(mut, has_one(lending_market))] + pub collateral_reserve: Account, + pub collateral_price: Account, + #[account(mut, has_one(lending_market), has_one(liquidity_mint), has_one(liquidity_vault))] + pub borrow_reserve: Account, + pub borrow_price: Account, + pub liquidity_mint: Account, + #[account(mut)] + pub liquidity_vault: Account, + #[account(mut)] + pub owner_liquidity: Account, + pub token_program: Program, +} + +impl BorrowObligationLiquidity { + #[inline(always)] + pub fn run(&mut self, amount: u64) -> Result<(), ProgramError> { + require!(amount > 0, LendingError::ZeroAmount); + let slot = now()?; + + require_keys_eq!( + self.obligation.collateral_reserve, + *self.collateral_reserve.address(), + LendingError::WrongReserve + ); + require_keys_eq!( + self.collateral_reserve.price_feed, + *self.collateral_price.address(), + LendingError::WrongReserve + ); + require_keys_eq!( + self.borrow_reserve.price_feed, + *self.borrow_price.address(), + LendingError::WrongReserve + ); + + let mut collateral = snapshot_reserve(&self.collateral_reserve); + accrue(&mut collateral, slot)?; + let mut borrow = snapshot_reserve(&self.borrow_reserve); + accrue(&mut borrow, slot)?; + let mut obligation = snapshot_obligation(&self.obligation); + if obligation.borrow_reserve != Address::default() { + require_keys_eq!( + obligation.borrow_reserve, + *self.borrow_reserve.address(), + LendingError::WrongReserve + ); + } + + // Borrow power from collateral value. + let collateral_total = total_liquidity( + collateral.available_liquidity, + collateral.borrowed_amount_scaled, + collateral.cumulative_borrow_rate_index, + )?; + let collateral_liquidity = mul_div_floor( + obligation.deposited_shares as u128, + collateral_total, + (collateral.share_mint_supply as u128).max(1), + )?; + let collateral_value = market_value( + u64::try_from(collateral_liquidity).map_err(|_| LendingError::MathOverflow)?, + collateral.liquidity_decimals, + price_scaled(&self.collateral_price, slot)?, + Rounding::Down, + )?; + let allowed = mul_div_floor(collateral_value, collateral.loan_to_value_bps as u128, BPS_DENOMINATOR)?; + + // Existing debt value + the new borrow, both rounded up. + let borrow_price = price_scaled(&self.borrow_price, slot)?; + let existing_debt = current_debt(obligation.borrowed_scaled, borrow.cumulative_borrow_rate_index)?; + let existing_value = market_value(existing_debt, borrow.liquidity_decimals, borrow_price, Rounding::Up)?; + let new_value = market_value(amount, borrow.liquidity_decimals, borrow_price, Rounding::Up)?; + let projected = existing_value.checked_add(new_value).ok_or(LendingError::MathOverflow)?; + require!(projected <= allowed, LendingError::BorrowTooLarge); + require!(amount <= borrow.available_liquidity, LendingError::InsufficientLiquidity); + + let scaled_added = mul_div_ceil(amount as u128, SCALE, borrow.cumulative_borrow_rate_index)?; + borrow.borrowed_amount_scaled = borrow + .borrowed_amount_scaled + .checked_add(scaled_added) + .ok_or(LendingError::MathOverflow)?; + borrow.available_liquidity = borrow + .available_liquidity + .checked_sub(amount) + .ok_or(LendingError::MathOverflow)?; + obligation.borrow_reserve = *self.borrow_reserve.address(); + obligation.borrowed_scaled = obligation + .borrowed_scaled + .checked_add(scaled_added) + .ok_or(LendingError::MathOverflow)?; + + let bump = [borrow.bump]; + let lending_market = borrow.lending_market; + let liquidity_mint = borrow.liquidity_mint; + let decimals = borrow.liquidity_decimals; + self.collateral_reserve.set_inner(collateral); + self.borrow_reserve.set_inner(borrow); + self.obligation.set_inner(obligation); + + let seeds = reserve_seeds!(lending_market, liquidity_mint, bump); + self.token_program + .transfer_checked( + &self.liquidity_vault, + &self.liquidity_mint, + &self.owner_liquidity, + &self.borrow_reserve, + amount, + decimals, + ) + .invoke_signed(&seeds) + } +} + +// --------------------------------------------------------------------------- +// repay_obligation_liquidity +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct RepayObligationLiquidity { + #[account(mut)] + pub repayer: Signer, + #[account(mut)] + pub obligation: Account, + #[account(mut, has_one(liquidity_mint), has_one(liquidity_vault))] + pub borrow_reserve: Account, + pub liquidity_mint: Account, + #[account(mut)] + pub liquidity_vault: Account, + #[account(mut)] + pub repayer_liquidity: Account, + pub token_program: Program, +} + +impl RepayObligationLiquidity { + #[inline(always)] + pub fn run(&mut self, amount: u64) -> Result<(), ProgramError> { + require!(amount > 0, LendingError::ZeroAmount); + let slot = now()?; + + require_keys_eq!( + self.obligation.borrow_reserve, + *self.borrow_reserve.address(), + LendingError::WrongReserve + ); + + let mut borrow = snapshot_reserve(&self.borrow_reserve); + accrue(&mut borrow, slot)?; + let mut obligation = snapshot_obligation(&self.obligation); + + let debt = current_debt(obligation.borrowed_scaled, borrow.cumulative_borrow_rate_index)?; + let repay = amount.min(debt); + require!(repay > 0, LendingError::ZeroAmount); + let scaled_removed = mul_div_floor(repay as u128, SCALE, borrow.cumulative_borrow_rate_index)? + .min(obligation.borrowed_scaled); + + borrow.borrowed_amount_scaled = borrow + .borrowed_amount_scaled + .checked_sub(scaled_removed) + .ok_or(LendingError::MathOverflow)?; + borrow.available_liquidity = borrow + .available_liquidity + .checked_add(repay) + .ok_or(LendingError::MathOverflow)?; + obligation.borrowed_scaled = obligation + .borrowed_scaled + .checked_sub(scaled_removed) + .ok_or(LendingError::MathOverflow)?; + + let decimals = borrow.liquidity_decimals; + self.borrow_reserve.set_inner(borrow); + self.obligation.set_inner(obligation); + + self.token_program + .transfer_checked( + &self.repayer_liquidity, + &self.liquidity_mint, + &self.liquidity_vault, + &self.repayer, + repay, + decimals, + ) + .invoke() + } +} + +// --------------------------------------------------------------------------- +// withdraw_obligation_collateral +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct WithdrawObligationCollateral { + #[account(mut)] + pub owner: Signer, + pub lending_market: Account, + #[account(mut, has_one(owner), has_one(lending_market), address = Obligation::seeds(lending_market.address(), owner.address()))] + pub obligation: Account, + #[account(mut, has_one(lending_market), has_one(share_mint))] + pub collateral_reserve: Account, + pub collateral_price: Account, + pub share_mint: Account, + /// Pass the borrow reserve + price when the obligation has debt; ignored when + /// `borrowed_scaled == 0` (nothing to value). + pub borrow_reserve: Account, + pub borrow_price: Account, + #[account(mut, address = ObligationVaultPda::seeds(collateral_reserve.address(), obligation.address()))] + pub obligation_vault: InterfaceAccount, + #[account(mut)] + pub owner_share: Account, + pub token_program: Program, +} + +impl WithdrawObligationCollateral { + #[inline(always)] + pub fn run(&mut self, shares: u64) -> Result<(), ProgramError> { + require!(shares > 0, LendingError::ZeroAmount); + let slot = now()?; + + require_keys_eq!( + self.obligation.collateral_reserve, + *self.collateral_reserve.address(), + LendingError::WrongReserve + ); + require_keys_eq!( + self.collateral_reserve.price_feed, + *self.collateral_price.address(), + LendingError::WrongReserve + ); + + let mut collateral = snapshot_reserve(&self.collateral_reserve); + accrue(&mut collateral, slot)?; + let mut obligation = snapshot_obligation(&self.obligation); + require!(obligation.deposited_shares >= shares, LendingError::WithdrawTooLarge); + + // Remaining collateral value after withdrawing `shares`. + let remaining_shares = obligation.deposited_shares - shares; + let collateral_total = total_liquidity( + collateral.available_liquidity, + collateral.borrowed_amount_scaled, + collateral.cumulative_borrow_rate_index, + )?; + let remaining_liquidity = mul_div_floor( + remaining_shares as u128, + collateral_total, + (collateral.share_mint_supply as u128).max(1), + )?; + let remaining_value = market_value( + u64::try_from(remaining_liquidity).map_err(|_| LendingError::MathOverflow)?, + collateral.liquidity_decimals, + price_scaled(&self.collateral_price, slot)?, + Rounding::Down, + )?; + let allowed = mul_div_floor(remaining_value, collateral.loan_to_value_bps as u128, BPS_DENOMINATOR)?; + + // Debt value (zero when the obligation has no borrow). + let debt_value = if obligation.borrowed_scaled > 0 { + require_keys_eq!( + obligation.borrow_reserve, + *self.borrow_reserve.address(), + LendingError::WrongReserve + ); + require_keys_eq!( + self.borrow_reserve.price_feed, + *self.borrow_price.address(), + LendingError::WrongReserve + ); + let mut borrow = snapshot_reserve(&self.borrow_reserve); + accrue(&mut borrow, slot)?; + let debt = current_debt(obligation.borrowed_scaled, borrow.cumulative_borrow_rate_index)?; + market_value(debt, borrow.liquidity_decimals, price_scaled(&self.borrow_price, slot)?, Rounding::Up)? + } else { + 0 + }; + require!(debt_value <= allowed, LendingError::WithdrawTooLarge); + + obligation.deposited_shares = remaining_shares; + + let decimals = self.share_mint.decimals; + let lending_market = obligation.lending_market; + let owner = obligation.owner; + let bump = [obligation.bump]; + self.collateral_reserve.set_inner(collateral); + self.obligation.set_inner(obligation); + + let seeds = obligation_seeds!(lending_market, owner, bump); + self.token_program + .transfer_checked( + &self.obligation_vault, + &self.share_mint, + &self.owner_share, + &self.obligation, + shares, + decimals, + ) + .invoke_signed(&seeds) + } +} + +// --------------------------------------------------------------------------- +// liquidate_obligation +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct LiquidateObligation { + #[account(mut)] + pub liquidator: Signer, + #[account(mut, has_one(lending_market))] + pub obligation: Account, + pub lending_market: Account, + #[account(mut, has_one(lending_market), has_one(share_mint))] + pub collateral_reserve: Account, + pub collateral_price: Account, + pub share_mint: Account, + #[account(mut, address = ObligationVaultPda::seeds(collateral_reserve.address(), obligation.address()))] + pub obligation_vault: InterfaceAccount, + #[account(mut)] + pub liquidator_collateral: Account, + #[account(mut, has_one(lending_market), has_one(liquidity_mint), has_one(liquidity_vault))] + pub borrow_reserve: Account, + pub borrow_price: Account, + pub liquidity_mint: Account, + #[account(mut)] + pub liquidity_vault: Account, + #[account(mut)] + pub liquidator_liquidity: Account, + pub token_program: Program, +} + +impl LiquidateObligation { + #[inline(always)] + pub fn run(&mut self, amount: u64) -> Result<(), ProgramError> { + require!(amount > 0, LendingError::ZeroAmount); + let slot = now()?; + + require_keys_eq!(self.obligation.collateral_reserve, *self.collateral_reserve.address(), LendingError::WrongReserve); + require_keys_eq!(self.obligation.borrow_reserve, *self.borrow_reserve.address(), LendingError::WrongReserve); + require_keys_eq!(self.collateral_reserve.price_feed, *self.collateral_price.address(), LendingError::WrongReserve); + require_keys_eq!(self.borrow_reserve.price_feed, *self.borrow_price.address(), LendingError::WrongReserve); + + let mut collateral = snapshot_reserve(&self.collateral_reserve); + accrue(&mut collateral, slot)?; + let mut borrow = snapshot_reserve(&self.borrow_reserve); + accrue(&mut borrow, slot)?; + let mut obligation = snapshot_obligation(&self.obligation); + + let collateral_price = price_scaled(&self.collateral_price, slot)?; + let borrow_price = price_scaled(&self.borrow_price, slot)?; + + // Health: unhealthy when debt value exceeds collateral value * liq threshold. + let collateral_total = total_liquidity( + collateral.available_liquidity, + collateral.borrowed_amount_scaled, + collateral.cumulative_borrow_rate_index, + )?; + let collateral_liquidity = mul_div_floor( + obligation.deposited_shares as u128, + collateral_total, + (collateral.share_mint_supply as u128).max(1), + )?; + let collateral_value = market_value( + u64::try_from(collateral_liquidity).map_err(|_| LendingError::MathOverflow)?, + collateral.liquidity_decimals, + collateral_price, + Rounding::Down, + )?; + let unhealthy_threshold = mul_div_floor(collateral_value, collateral.liquidation_threshold_bps as u128, BPS_DENOMINATOR)?; + let debt = current_debt(obligation.borrowed_scaled, borrow.cumulative_borrow_rate_index)?; + let debt_value = market_value(debt, borrow.liquidity_decimals, borrow_price, Rounding::Up)?; + require!(debt_value > unhealthy_threshold, LendingError::ObligationHealthy); + + // Repay capped by the close factor. + let max_repay = mul_div_floor(debt as u128, collateral.close_factor_bps as u128, BPS_DENOMINATOR)?; + let repay = amount.min(u64::try_from(max_repay).map_err(|_| LendingError::MathOverflow)?); + require!(repay > 0, LendingError::ZeroAmount); + + // Seize collateral worth repay value + bonus, converted to share tokens. + let repay_value = market_value(repay, borrow.liquidity_decimals, borrow_price, Rounding::Down)?; + let bonus = mul_div_floor(repay_value, collateral.liquidation_bonus_bps as u128, BPS_DENOMINATOR)?; + let seize_value = repay_value.checked_add(bonus).ok_or(LendingError::MathOverflow)?; + let seize_liquidity = value_to_amount(seize_value, collateral.liquidity_decimals, collateral_price, Rounding::Down)?; + let seize_shares = mul_div_floor( + seize_liquidity as u128, + collateral.share_mint_supply as u128, + collateral_total.max(1), + )?; + let seize_shares = u64::try_from(seize_shares) + .map_err(|_| LendingError::MathOverflow)? + .min(obligation.deposited_shares); + require!(seize_shares > 0, LendingError::ZeroAmount); + + let scaled_removed = mul_div_floor(repay as u128, SCALE, borrow.cumulative_borrow_rate_index)? + .min(obligation.borrowed_scaled); + + borrow.borrowed_amount_scaled = borrow.borrowed_amount_scaled.checked_sub(scaled_removed).ok_or(LendingError::MathOverflow)?; + borrow.available_liquidity = borrow.available_liquidity.checked_add(repay).ok_or(LendingError::MathOverflow)?; + obligation.borrowed_scaled = obligation.borrowed_scaled.checked_sub(scaled_removed).ok_or(LendingError::MathOverflow)?; + obligation.deposited_shares = obligation.deposited_shares.checked_sub(seize_shares).ok_or(LendingError::MathOverflow)?; + + let share_decimals = self.share_mint.decimals; + let borrow_decimals = borrow.liquidity_decimals; + let lending_market = obligation.lending_market; + let owner = obligation.owner; + let bump = [obligation.bump]; + self.collateral_reserve.set_inner(collateral); + self.borrow_reserve.set_inner(borrow); + self.obligation.set_inner(obligation); + + // Liquidator repays the debt token... + self.token_program + .transfer_checked( + &self.liquidator_liquidity, + &self.liquidity_mint, + &self.liquidity_vault, + &self.liquidator, + repay, + borrow_decimals, + ) + .invoke()?; + + // ...and receives the seized collateral share tokens (obligation PDA signs). + let seeds = obligation_seeds!(lending_market, owner, bump); + self.token_program + .transfer_checked( + &self.obligation_vault, + &self.share_mint, + &self.liquidator_collateral, + &self.obligation, + seize_shares, + share_decimals, + ) + .invoke_signed(&seeds) + } +} diff --git a/finance/lending/quasar/src/instructions/supply.rs b/finance/lending/quasar/src/instructions/supply.rs new file mode 100644 index 00000000..ae0f5750 --- /dev/null +++ b/finance/lending/quasar/src/instructions/supply.rs @@ -0,0 +1,177 @@ +use { + crate::{ + error::LendingError, + logic::{accrue, now, snapshot_reserve}, + math::{mul_div_floor, total_liquidity}, + state::Reserve, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +/// Reserve PDA signer seeds, used to authorize mint/transfer from the vault. +macro_rules! reserve_seeds { + ($lending_market:expr, $liquidity_mint:expr, $bump:expr) => { + [ + Seed::from(crate::constants::RESERVE_SEED), + Seed::from($lending_market.as_ref()), + Seed::from($liquidity_mint.as_ref()), + Seed::from($bump.as_ref()), + ] + }; +} +pub(crate) use reserve_seeds; + +// --------------------------------------------------------------------------- +// deposit_reserve_liquidity +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct DepositReserveLiquidity { + #[account(mut)] + pub supplier: Signer, + #[account(mut, has_one(liquidity_mint), has_one(liquidity_vault), has_one(share_mint))] + pub reserve: Account, + pub liquidity_mint: Account, + #[account(mut)] + pub liquidity_vault: Account, + #[account(mut)] + pub share_mint: Account, + #[account(mut)] + pub supplier_liquidity: Account, + #[account(mut)] + pub supplier_share: Account, + pub token_program: Program, +} + +impl DepositReserveLiquidity { + #[inline(always)] + pub fn run(&mut self, amount: u64) -> Result<(), ProgramError> { + require!(amount > 0, LendingError::ZeroAmount); + let slot = now()?; + + let mut reserve = snapshot_reserve(&self.reserve); + accrue(&mut reserve, slot)?; + + let total = total_liquidity( + reserve.available_liquidity, + reserve.borrowed_amount_scaled, + reserve.cumulative_borrow_rate_index, + )?; + let shares = if reserve.share_mint_supply == 0 { + amount as u128 + } else { + mul_div_floor(amount as u128, reserve.share_mint_supply as u128, total)? + }; + require!(shares > 0, LendingError::DepositTooSmall); + let shares = u64::try_from(shares).map_err(|_| LendingError::MathOverflow)?; + + reserve.available_liquidity = reserve + .available_liquidity + .checked_add(amount) + .ok_or(LendingError::MathOverflow)?; + reserve.share_mint_supply = reserve + .share_mint_supply + .checked_add(shares) + .ok_or(LendingError::MathOverflow)?; + + let decimals = reserve.liquidity_decimals; + let bump = [reserve.bump]; + let lending_market = reserve.lending_market; + let liquidity_mint = reserve.liquidity_mint; + self.reserve.set_inner(reserve); + + self.token_program + .transfer_checked( + &self.supplier_liquidity, + &self.liquidity_mint, + &self.liquidity_vault, + &self.supplier, + amount, + decimals, + ) + .invoke()?; + + let seeds = reserve_seeds!(lending_market, liquidity_mint, bump); + self.token_program + .mint_to(&self.share_mint, &self.supplier_share, &self.reserve, shares) + .invoke_signed(&seeds) + } +} + +// --------------------------------------------------------------------------- +// redeem_reserve_collateral +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct RedeemReserveCollateral { + #[account(mut)] + pub supplier: Signer, + #[account(mut, has_one(liquidity_mint), has_one(liquidity_vault), has_one(share_mint))] + pub reserve: Account, + pub liquidity_mint: Account, + #[account(mut)] + pub liquidity_vault: Account, + #[account(mut)] + pub share_mint: Account, + #[account(mut)] + pub supplier_liquidity: Account, + #[account(mut)] + pub supplier_share: Account, + pub token_program: Program, +} + +impl RedeemReserveCollateral { + #[inline(always)] + pub fn run(&mut self, shares: u64) -> Result<(), ProgramError> { + require!(shares > 0, LendingError::ZeroAmount); + let slot = now()?; + + let mut reserve = snapshot_reserve(&self.reserve); + accrue(&mut reserve, slot)?; + require!(reserve.share_mint_supply > 0, LendingError::InsufficientLiquidity); + + let total = total_liquidity( + reserve.available_liquidity, + reserve.borrowed_amount_scaled, + reserve.cumulative_borrow_rate_index, + )?; + let liquidity = mul_div_floor(shares as u128, total, reserve.share_mint_supply as u128)?; + let liquidity = u64::try_from(liquidity).map_err(|_| LendingError::MathOverflow)?; + require!( + liquidity <= reserve.available_liquidity, + LendingError::InsufficientLiquidity + ); + + reserve.available_liquidity = reserve + .available_liquidity + .checked_sub(liquidity) + .ok_or(LendingError::MathOverflow)?; + reserve.share_mint_supply = reserve + .share_mint_supply + .checked_sub(shares) + .ok_or(LendingError::MathOverflow)?; + + let decimals = reserve.liquidity_decimals; + let bump = [reserve.bump]; + let lending_market = reserve.lending_market; + let liquidity_mint = reserve.liquidity_mint; + self.reserve.set_inner(reserve); + + self.token_program + .burn(&self.supplier_share, &self.share_mint, &self.supplier, shares) + .invoke()?; + + let seeds = reserve_seeds!(lending_market, liquidity_mint, bump); + self.token_program + .transfer_checked( + &self.liquidity_vault, + &self.liquidity_mint, + &self.supplier_liquidity, + &self.reserve, + liquidity, + decimals, + ) + .invoke_signed(&seeds) + } +} diff --git a/finance/lending/quasar/src/lib.rs b/finance/lending/quasar/src/lib.rs new file mode 100644 index 00000000..56d85132 --- /dev/null +++ b/finance/lending/quasar/src/lib.rs @@ -0,0 +1,136 @@ +#![cfg_attr(not(test), no_std)] +// Quasar's `#[account]` / `#[derive(Accounts)]` macros drive account validation +// and CPIs from struct fields that handler code never reads directly, which +// rustc flags as dead code. The shipped Quasar examples allow it crate-wide. +#![allow(dead_code)] + +//! A Kamino/Solend-style borrow/lend program, ported to Quasar. +//! +//! Quasar accounts are fixed-size and zero-copy, so this port models an isolated +//! single-collateral, single-borrow position per obligation (mirroring how the +//! shipped Quasar `escrow`/`vault` examples use fixed-size accounts), and accrues +//! interest inline rather than via a separate `refresh` instruction. It keeps +//! every core lending technique: share-token deposits, a kinked-curve interest +//! index, oracle-priced health, and close-factor liquidation with a bonus. + +use quasar_lang::prelude::*; + +mod constants; +mod error; +mod instructions; +mod logic; +mod math; +mod state; + +#[cfg(test)] +mod tests; + +use instructions::*; + +declare_id!("RDZr26xXfPx8wqQfxcvJLWccp5ep7jQpnxcbCWPiPQq"); + +#[program] +mod quasar_lending { + use super::*; + + #[instruction(discriminator = 0)] + pub fn init_lending_market(ctx: Ctx) -> Result<(), ProgramError> { + ctx.accounts.run(&ctx.bumps) + } + + #[instruction(discriminator = 1)] + #[allow(clippy::too_many_arguments)] + pub fn init_reserve( + ctx: Ctx, + loan_to_value_bps: u16, + liquidation_threshold_bps: u16, + liquidation_bonus_bps: u16, + close_factor_bps: u16, + optimal_utilization_bps: u16, + min_borrow_rate_bps: u16, + optimal_borrow_rate_bps: u16, + max_borrow_rate_bps: u16, + ) -> Result<(), ProgramError> { + ctx.accounts.run( + loan_to_value_bps, + liquidation_threshold_bps, + liquidation_bonus_bps, + close_factor_bps, + optimal_utilization_bps, + min_borrow_rate_bps, + optimal_borrow_rate_bps, + max_borrow_rate_bps, + &ctx.bumps, + ) + } + + #[instruction(discriminator = 2)] + pub fn set_price( + ctx: Ctx, + price_mantissa: i128, + exponent: i32, + ) -> Result<(), ProgramError> { + ctx.accounts.run(price_mantissa, exponent, &ctx.bumps) + } + + #[instruction(discriminator = 3)] + pub fn deposit_reserve_liquidity( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { + ctx.accounts.run(amount) + } + + #[instruction(discriminator = 4)] + pub fn redeem_reserve_collateral( + ctx: Ctx, + shares: u64, + ) -> Result<(), ProgramError> { + ctx.accounts.run(shares) + } + + #[instruction(discriminator = 5)] + pub fn init_obligation(ctx: Ctx) -> Result<(), ProgramError> { + ctx.accounts.run(&ctx.bumps) + } + + #[instruction(discriminator = 6)] + pub fn deposit_obligation_collateral( + ctx: Ctx, + shares: u64, + ) -> Result<(), ProgramError> { + ctx.accounts.run(shares) + } + + #[instruction(discriminator = 7)] + pub fn withdraw_obligation_collateral( + ctx: Ctx, + shares: u64, + ) -> Result<(), ProgramError> { + ctx.accounts.run(shares) + } + + #[instruction(discriminator = 8)] + pub fn borrow_obligation_liquidity( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { + ctx.accounts.run(amount) + } + + #[instruction(discriminator = 9)] + pub fn repay_obligation_liquidity( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { + ctx.accounts.run(amount) + } + + #[instruction(discriminator = 10)] + pub fn liquidate_obligation( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { + ctx.accounts.run(amount) + } +} diff --git a/finance/lending/quasar/src/logic.rs b/finance/lending/quasar/src/logic.rs new file mode 100644 index 00000000..c717b1f1 --- /dev/null +++ b/finance/lending/quasar/src/logic.rs @@ -0,0 +1,90 @@ +//! Helpers that bridge Quasar's zero-copy accounts and the integer math in +//! [`crate::math`]. Account scalar getters return Pod types, so these read each +//! field into a native-typed `*Inner` snapshot that math operates on and +//! `set_inner` writes back. + +use quasar_lang::{prelude::*, sysvars::Sysvar}; + +use crate::{ + constants::{FIXED_POINT_SCALE, MAX_PRICE_STALENESS_SLOTS}, + error::LendingError, + math::{accrue_index, price_mantissa_to_scaled}, + state::{Obligation, ObligationInner, PriceFeed, Reserve, ReserveInner}, +}; + +/// Current slot as a native `u64`. +pub fn now() -> Result { + Ok(u64::from(Clock::get()?.slot)) +} + +/// Read a reserve into a native-typed, mutable snapshot. +pub fn snapshot_reserve(reserve: &Account) -> ReserveInner { + ReserveInner { + lending_market: reserve.lending_market, + liquidity_mint: reserve.liquidity_mint, + liquidity_vault: reserve.liquidity_vault, + share_mint: reserve.share_mint, + price_feed: reserve.price_feed, + available_liquidity: u64::from(reserve.available_liquidity), + share_mint_supply: u64::from(reserve.share_mint_supply), + borrowed_amount_scaled: u128::from(reserve.borrowed_amount_scaled), + cumulative_borrow_rate_index: u128::from(reserve.cumulative_borrow_rate_index), + last_update_slot: u64::from(reserve.last_update_slot), + liquidity_decimals: reserve.liquidity_decimals, + loan_to_value_bps: u16::from(reserve.loan_to_value_bps), + liquidation_threshold_bps: u16::from(reserve.liquidation_threshold_bps), + liquidation_bonus_bps: u16::from(reserve.liquidation_bonus_bps), + close_factor_bps: u16::from(reserve.close_factor_bps), + optimal_utilization_bps: u16::from(reserve.optimal_utilization_bps), + min_borrow_rate_bps: u16::from(reserve.min_borrow_rate_bps), + optimal_borrow_rate_bps: u16::from(reserve.optimal_borrow_rate_bps), + max_borrow_rate_bps: u16::from(reserve.max_borrow_rate_bps), + bump: reserve.bump, + } +} + +/// Read an obligation into a native-typed, mutable snapshot. +pub fn snapshot_obligation(obligation: &Account) -> ObligationInner { + ObligationInner { + lending_market: obligation.lending_market, + owner: obligation.owner, + collateral_reserve: obligation.collateral_reserve, + deposited_shares: u64::from(obligation.deposited_shares), + borrow_reserve: obligation.borrow_reserve, + borrowed_scaled: u128::from(obligation.borrowed_scaled), + bump: obligation.bump, + } +} + +/// Advance a reserve snapshot's interest index to `slot` (Solend-style: a single +/// `index *= 1 + rate_per_slot * elapsed` per call, compounding across calls). +pub fn accrue(reserve: &mut ReserveInner, slot: u64) -> Result<(), ProgramError> { + reserve.cumulative_borrow_rate_index = accrue_index( + reserve.cumulative_borrow_rate_index, + reserve.borrowed_amount_scaled, + reserve.available_liquidity, + reserve.last_update_slot, + slot, + reserve.optimal_utilization_bps, + reserve.min_borrow_rate_bps, + reserve.optimal_borrow_rate_bps, + reserve.max_borrow_rate_bps, + )?; + reserve.last_update_slot = slot; + Ok(()) +} + +/// The feed's price scaled by `FIXED_POINT_SCALE`, after staleness + positivity checks. +pub fn price_scaled(feed: &Account, slot: u64) -> Result { + let last_updated = u64::from(feed.last_updated_slot); + let age = slot + .checked_sub(last_updated) + .ok_or(LendingError::MathOverflow)?; + require!(age <= MAX_PRICE_STALENESS_SLOTS, LendingError::StalePrice); + let mantissa = i128::from(feed.price_mantissa); + require!(mantissa > 0, LendingError::InvalidOraclePrice); + price_mantissa_to_scaled(mantissa as u128, i32::from(feed.exponent)) +} + +/// `FIXED_POINT_SCALE` re-export for handlers that scale borrow principal. +pub const SCALE: u128 = FIXED_POINT_SCALE; diff --git a/finance/lending/quasar/src/math.rs b/finance/lending/quasar/src/math.rs new file mode 100644 index 00000000..b25b4ced --- /dev/null +++ b/finance/lending/quasar/src/math.rs @@ -0,0 +1,223 @@ +//! Integer-only money math (no floats, no fixed-point crates), shared by the +//! handlers. Ratios are scaled by `FIXED_POINT_SCALE`; conversions round in the +//! protocol's favour. + +use quasar_lang::prelude::*; + +use crate::{ + constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, FIXED_POINT_SCALE_DECIMALS, SLOTS_PER_YEAR}, + error::LendingError, +}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Rounding { + Down, + Up, +} + +pub fn ten_pow(exponent: u32) -> Result { + 10u128.checked_pow(exponent).ok_or(LendingError::MathOverflow.into()) +} + +pub fn mul_div_floor(a: u128, b: u128, denominator: u128) -> Result { + require!(denominator > 0, LendingError::MathOverflow); + let product = a.checked_mul(b).ok_or(LendingError::MathOverflow)?; + Ok(product.checked_div(denominator).ok_or(LendingError::MathOverflow)?) +} + +pub fn mul_div_ceil(a: u128, b: u128, denominator: u128) -> Result { + require!(denominator > 0, LendingError::MathOverflow); + let product = a.checked_mul(b).ok_or(LendingError::MathOverflow)?; + let rounding = denominator.checked_sub(1).ok_or(LendingError::MathOverflow)?; + Ok(product + .checked_add(rounding) + .ok_or(LendingError::MathOverflow)? + .checked_div(denominator) + .ok_or(LendingError::MathOverflow)?) +} + +fn mul_div(a: u128, b: u128, denominator: u128, rounding: Rounding) -> Result { + match rounding { + Rounding::Down => mul_div_floor(a, b, denominator), + Rounding::Up => mul_div_ceil(a, b, denominator), + } +} + +/// `price_scaled = real_price * FIXED_POINT_SCALE`, where +/// `real_price = mantissa * 10^exponent`. The exponent and the fixed-point scale +/// are folded into one power of ten to stay overflow-safe for high prices. +pub fn price_mantissa_to_scaled(mantissa: u128, exponent: i32) -> Result { + let net = exponent + .checked_add(FIXED_POINT_SCALE_DECIMALS) + .ok_or(LendingError::MathOverflow)?; + if net >= 0 { + Ok(mantissa + .checked_mul(ten_pow(net as u32)?) + .ok_or(LendingError::MathOverflow)?) + } else { + Ok(mantissa + .checked_div(ten_pow((-net) as u32)?) + .ok_or(LendingError::MathOverflow)?) + } +} + +/// Quote-currency value (FIXED_POINT_SCALE-scaled) of `amount` base units of a +/// token with `decimals`, given `price_scaled`. +pub fn market_value( + amount: u64, + decimals: u8, + price_scaled: u128, + rounding: Rounding, +) -> Result { + mul_div(amount as u128, price_scaled, ten_pow(decimals as u32)?, rounding) +} + +/// Inverse of [`market_value`]: base units of a token worth `value_scaled`. +pub fn value_to_amount( + value_scaled: u128, + decimals: u8, + price_scaled: u128, + rounding: Rounding, +) -> Result { + let amount = mul_div(value_scaled, ten_pow(decimals as u32)?, price_scaled, rounding)?; + u64::try_from(amount).map_err(|_| LendingError::MathOverflow.into()) +} + +// --- reserve interest / share helpers (free functions over reserve fields) --- + +/// Live total debt owed to the pool, rounded up (protocol-favourable). +pub fn current_debt(borrowed_scaled: u128, index: u128) -> Result { + let debt = mul_div_ceil(borrowed_scaled, index, FIXED_POINT_SCALE)?; + u64::try_from(debt).map_err(|_| LendingError::MathOverflow.into()) +} + +/// Available liquidity plus live debt — what the share token is a claim on. +pub fn total_liquidity( + available: u64, + borrowed_scaled: u128, + index: u128, +) -> Result { + (available as u128) + .checked_add(current_debt(borrowed_scaled, index)? as u128) + .ok_or(LendingError::MathOverflow.into()) +} + +/// Borrowed fraction of the pool in basis points (0..=10_000). +pub fn utilization_bps( + available: u64, + borrowed_scaled: u128, + index: u128, +) -> Result { + let total = total_liquidity(available, borrowed_scaled, index)?; + if total == 0 { + return Ok(0); + } + mul_div_floor(current_debt(borrowed_scaled, index)? as u128, BPS_DENOMINATOR, total) +} + +/// Per-slot borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve. +#[allow(clippy::too_many_arguments)] +pub fn borrow_rate_per_slot( + utilization: u128, + optimal_utilization_bps: u16, + min_rate_bps: u16, + optimal_rate_bps: u16, + max_rate_bps: u16, +) -> Result { + let optimal_utilization = optimal_utilization_bps as u128; + let apr_bps = if utilization <= optimal_utilization { + let range = (optimal_rate_bps as u128) + .checked_sub(min_rate_bps as u128) + .ok_or(LendingError::MathOverflow)?; + (min_rate_bps as u128) + .checked_add(mul_div_floor(range, utilization, optimal_utilization.max(1))?) + .ok_or(LendingError::MathOverflow)? + } else { + let range = (max_rate_bps as u128) + .checked_sub(optimal_rate_bps as u128) + .ok_or(LendingError::MathOverflow)?; + let above = utilization + .checked_sub(optimal_utilization) + .ok_or(LendingError::MathOverflow)?; + let span = BPS_DENOMINATOR + .checked_sub(optimal_utilization) + .ok_or(LendingError::MathOverflow)?; + (optimal_rate_bps as u128) + .checked_add(mul_div_floor(range, above, span.max(1))?) + .ok_or(LendingError::MathOverflow)? + }; + let denominator = BPS_DENOMINATOR + .checked_mul(SLOTS_PER_YEAR) + .ok_or(LendingError::MathOverflow)?; + mul_div_floor(apr_bps, FIXED_POINT_SCALE, denominator) +} + +/// Advance the interest index for elapsed slots: +/// `new_index = index * (1 + rate_per_slot * elapsed)`. +#[allow(clippy::too_many_arguments)] +pub fn accrue_index( + index: u128, + borrowed_scaled: u128, + available: u64, + last_update_slot: u64, + now: u64, + optimal_utilization_bps: u16, + min_rate_bps: u16, + optimal_rate_bps: u16, + max_rate_bps: u16, +) -> Result { + let elapsed = now + .checked_sub(last_update_slot) + .ok_or(LendingError::MathOverflow)?; + if elapsed == 0 || borrowed_scaled == 0 { + return Ok(index); + } + let utilization = utilization_bps(available, borrowed_scaled, index)?; + let rate = borrow_rate_per_slot( + utilization, + optimal_utilization_bps, + min_rate_bps, + optimal_rate_bps, + max_rate_bps, + )?; + let growth = FIXED_POINT_SCALE + .checked_add(rate.checked_mul(elapsed as u128).ok_or(LendingError::MathOverflow)?) + .ok_or(LendingError::MathOverflow)?; + mul_div_floor(index, growth, FIXED_POINT_SCALE) +} + +pub fn validate_config( + loan_to_value_bps: u16, + liquidation_threshold_bps: u16, + liquidation_bonus_bps: u16, + close_factor_bps: u16, + optimal_utilization_bps: u16, + min_borrow_rate_bps: u16, + optimal_borrow_rate_bps: u16, + max_borrow_rate_bps: u16, +) -> Result<(), ProgramError> { + let within = |value: u16| (value as u128) <= BPS_DENOMINATOR; + require!( + within(loan_to_value_bps) + && within(liquidation_threshold_bps) + && within(liquidation_bonus_bps) + && within(close_factor_bps) + && within(optimal_utilization_bps), + LendingError::InvalidConfig + ); + require!(close_factor_bps > 0, LendingError::InvalidConfig); + require!( + optimal_utilization_bps > 0 && (optimal_utilization_bps as u128) < BPS_DENOMINATOR, + LendingError::InvalidConfig + ); + require!( + loan_to_value_bps <= liquidation_threshold_bps, + LendingError::InvalidConfig + ); + require!( + min_borrow_rate_bps <= optimal_borrow_rate_bps + && optimal_borrow_rate_bps <= max_borrow_rate_bps, + LendingError::InvalidConfig + ); + Ok(()) +} diff --git a/finance/lending/quasar/src/state.rs b/finance/lending/quasar/src/state.rs new file mode 100644 index 00000000..69188285 --- /dev/null +++ b/finance/lending/quasar/src/state.rs @@ -0,0 +1,85 @@ +//! Program accounts. Quasar accounts are zero-copy; fixed-size fields only +//! (no `Vec`), which is why this Quasar port models an isolated single-collateral, +//! single-borrow position per obligation rather than the Anchor version's +//! multi-asset obligation. + +use quasar_lang::prelude::*; + +/// Top-level market config. PDA: `["lending_market", owner]`. +#[account(discriminator = 1, set_inner)] +#[seeds(b"lending_market", owner: Address)] +pub struct LendingMarket { + pub owner: Address, + pub quote_mint: Address, + pub bump: u8, +} + +/// One asset's pool. PDA: `["reserve", lending_market, liquidity_mint]`. +/// The reserve PDA is the authority of both `liquidity_vault` and `share_mint`. +#[account(discriminator = 2, set_inner)] +#[seeds(b"reserve", lending_market: Address, liquidity_mint: Address)] +pub struct Reserve { + pub lending_market: Address, + pub liquidity_mint: Address, + pub liquidity_vault: Address, + pub share_mint: Address, + pub price_feed: Address, + pub available_liquidity: u64, + pub share_mint_supply: u64, + pub borrowed_amount_scaled: u128, + pub cumulative_borrow_rate_index: u128, + pub last_update_slot: u64, + pub liquidity_decimals: u8, + pub loan_to_value_bps: u16, + pub liquidation_threshold_bps: u16, + pub liquidation_bonus_bps: u16, + pub close_factor_bps: u16, + pub optimal_utilization_bps: u16, + pub min_borrow_rate_bps: u16, + pub optimal_borrow_rate_bps: u16, + pub max_borrow_rate_bps: u16, + pub bump: u8, +} + +/// A borrower's isolated position. PDA: `["obligation", lending_market, owner]`. +/// `collateral_reserve` / `borrow_reserve` are the zero address until first used. +#[account(discriminator = 3, set_inner)] +#[seeds(b"obligation", lending_market: Address, owner: Address)] +pub struct Obligation { + pub lending_market: Address, + pub owner: Address, + pub collateral_reserve: Address, + pub deposited_shares: u64, + pub borrow_reserve: Address, + pub borrowed_scaled: u128, + pub bump: u8, +} + +/// Switchboard-On-Demand-shaped price feed. PDA: `["price_feed", mint]`. +/// `price = price_mantissa * 10^exponent`; freshness is checked in slots. +/// In production this account would be the real Switchboard feed. +#[account(discriminator = 4, set_inner)] +#[seeds(b"price_feed", mint: Address)] +pub struct PriceFeed { + pub mint: Address, + pub price_mantissa: i128, + pub exponent: i32, + pub last_updated_slot: u64, + pub authority: Address, + pub bump: u8, +} + +/// PDA marker for a reserve's liquidity vault: `["liquidity_vault", reserve]`. +#[derive(Seeds)] +#[seeds(b"liquidity_vault", reserve: Address)] +pub struct LiquidityVaultPda; + +/// PDA marker for a reserve's share mint: `["share_mint", reserve]`. +#[derive(Seeds)] +#[seeds(b"share_mint", reserve: Address)] +pub struct ShareMintPda; + +/// PDA marker for an obligation's collateral vault: `["obligation_vault", reserve, obligation]`. +#[derive(Seeds)] +#[seeds(b"obligation_vault", reserve: Address, obligation: Address)] +pub struct ObligationVaultPda; diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs new file mode 100644 index 00000000..42eb7691 --- /dev/null +++ b/finance/lending/quasar/src/tests.rs @@ -0,0 +1,487 @@ +extern crate std; + +use { + alloc::{vec, vec::Vec}, + quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, + solana_instruction::AccountMeta, + spl_token_interface::state::{Account as SplToken, AccountState, Mint as SplMint}, + std::fs, +}; + +// Prices are passed as `mantissa * 10^-18` (Switchboard-shaped). +const EXP: i32 = -18; +fn dollars(whole: u64) -> i128 { + (whole as i128) * 1_000_000_000_000_000_000 +} +fn cents(amount: u64) -> i128 { + (amount as i128) * 10_000_000_000_000_000 +} + +const DECIMALS: u8 = 6; +const UNIT: u64 = 1_000_000; // 1 token at 6 decimals + +// Deterministic addresses. +const OWNER: Pubkey = Pubkey::new_from_array([1; 32]); +const SUPPLIER: Pubkey = Pubkey::new_from_array([2; 32]); +const BORROWER: Pubkey = Pubkey::new_from_array([3; 32]); +const LIQUIDATOR: Pubkey = Pubkey::new_from_array([4; 32]); +const COLL_MINT: Pubkey = Pubkey::new_from_array([5; 32]); +const BORROW_MINT: Pubkey = Pubkey::new_from_array([6; 32]); +const QUOTE_MINT: Pubkey = Pubkey::new_from_array([7; 32]); +// Token accounts. +const SUPPLIER_BORROW: Pubkey = Pubkey::new_from_array([10; 32]); +const SUPPLIER_BORROW_SHARE: Pubkey = Pubkey::new_from_array([11; 32]); +const BORROWER_COLL: Pubkey = Pubkey::new_from_array([12; 32]); +const BORROWER_COLL_SHARE: Pubkey = Pubkey::new_from_array([13; 32]); +const BORROWER_BORROW: Pubkey = Pubkey::new_from_array([14; 32]); +const LIQUIDATOR_BORROW: Pubkey = Pubkey::new_from_array([15; 32]); +const LIQUIDATOR_COLL_SHARE: Pubkey = Pubkey::new_from_array([16; 32]); + +fn token_program() -> Pubkey { + quasar_svm::SPL_TOKEN_PROGRAM_ID +} +fn system_program() -> Pubkey { + quasar_svm::system_program::ID +} + +fn pda(seeds: &[&[u8]]) -> (Pubkey, u8) { + Pubkey::find_program_address(seeds, &crate::ID) +} + +fn meta(address: Pubkey, writable: bool, signer: bool) -> AccountMeta { + if writable { + let mut m = AccountMeta::new(address.into(), signer); + m.is_signer = signer; + m + } else { + AccountMeta::new_readonly(address.into(), signer) + } +} + +fn system(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) +} +fn empty(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: system_program(), + executable: false, + } +} +fn mint(address: Pubkey, authority: Pubkey) -> Account { + quasar_svm::token::create_keyed_mint_account( + &address, + &SplMint { + mint_authority: Some(authority).into(), + supply: 1_000_000_000_000, + decimals: DECIMALS, + is_initialized: true, + freeze_authority: None.into(), + }, + ) +} +fn token(address: Pubkey, the_mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + quasar_svm::token::create_keyed_token_account( + &address, + &SplToken { + mint: the_mint, + owner, + amount, + state: AccountState::Initialized, + ..SplToken::default() + }, + ) +} + +/// Read an SPL token account's amount from committed bytes (offset 64..72). +fn balance(result: &quasar_svm::ExecutionResult, address: Pubkey) -> u64 { + let account = result.account(&address).expect("account present"); + u64::from_le_bytes(account.data[64..72].try_into().unwrap()) +} + +struct World { + svm: QuasarSvm, + market: Pubkey, + coll_reserve: Pubkey, + coll_vault: Pubkey, + coll_share_mint: Pubkey, + coll_price: Pubkey, + borrow_reserve: Pubkey, + borrow_vault: Pubkey, + borrow_share_mint: Pubkey, + borrow_price: Pubkey, + obligation: Pubkey, + obligation_vault: Pubkey, +} + +impl World { + fn new() -> Self { + let elf = fs::read("target/deploy/quasar_lending.so").unwrap(); + let mut svm = QuasarSvm::new() + .with_program(&crate::ID, &elf) + .with_token_program(); + + let (market, _) = pda(&[b"lending_market", OWNER.as_ref()]); + let (coll_reserve, _) = pda(&[b"reserve", market.as_ref(), COLL_MINT.as_ref()]); + let (borrow_reserve, _) = pda(&[b"reserve", market.as_ref(), BORROW_MINT.as_ref()]); + let (coll_vault, _) = pda(&[b"liquidity_vault", coll_reserve.as_ref()]); + let (borrow_vault, _) = pda(&[b"liquidity_vault", borrow_reserve.as_ref()]); + let (coll_share_mint, _) = pda(&[b"share_mint", coll_reserve.as_ref()]); + let (borrow_share_mint, _) = pda(&[b"share_mint", borrow_reserve.as_ref()]); + let (coll_price, _) = pda(&[b"price_feed", COLL_MINT.as_ref()]); + let (borrow_price, _) = pda(&[b"price_feed", BORROW_MINT.as_ref()]); + let (obligation, _) = pda(&[b"obligation", market.as_ref(), BORROWER.as_ref()]); + let (obligation_vault, _) = + pda(&[b"obligation_vault", coll_reserve.as_ref(), obligation.as_ref()]); + + for account in [ + system(OWNER), + system(SUPPLIER), + system(BORROWER), + system(LIQUIDATOR), + mint(COLL_MINT, OWNER), + mint(BORROW_MINT, OWNER), + mint(QUOTE_MINT, OWNER), + // PDAs created by the program. + empty(market), + empty(coll_reserve), + empty(borrow_reserve), + empty(coll_vault), + empty(borrow_vault), + empty(coll_share_mint), + empty(borrow_share_mint), + empty(coll_price), + empty(borrow_price), + empty(obligation), + empty(obligation_vault), + // Funded user token accounts. + token(SUPPLIER_BORROW, BORROW_MINT, SUPPLIER, 1_000 * UNIT), + token(SUPPLIER_BORROW_SHARE, borrow_share_mint, SUPPLIER, 0), + token(BORROWER_COLL, COLL_MINT, BORROWER, 1_000 * UNIT), + token(BORROWER_COLL_SHARE, coll_share_mint, BORROWER, 0), + token(BORROWER_BORROW, BORROW_MINT, BORROWER, 0), + token(LIQUIDATOR_BORROW, BORROW_MINT, LIQUIDATOR, 1_000 * UNIT), + token(LIQUIDATOR_COLL_SHARE, coll_share_mint, LIQUIDATOR, 0), + ] { + svm.set_account(account); + } + + World { + svm, + market, + coll_reserve, + coll_vault, + coll_share_mint, + coll_price, + borrow_reserve, + borrow_vault, + borrow_share_mint, + borrow_price, + obligation, + obligation_vault, + } + } + + fn run(&mut self, data: Vec, metas: Vec) -> quasar_svm::ExecutionResult { + let instruction = Instruction { + program_id: crate::ID, + accounts: metas, + data, + }; + self.svm.process_instruction(&instruction, &[]) + } + + fn init_market(&mut self) { + let metas = vec![ + meta(OWNER, true, true), + meta(self.market, true, false), + meta(QUOTE_MINT, false, false), + meta(system_program(), false, false), + ]; + self.run(vec![0], metas).assert_success(); + } + + fn set_price(&mut self, the_mint: Pubkey, price_feed: Pubkey, mantissa: i128) { + let mut data = vec![2u8]; + data.extend_from_slice(&mantissa.to_le_bytes()); + data.extend_from_slice(&EXP.to_le_bytes()); + let metas = vec![ + meta(OWNER, true, true), + meta(price_feed, true, false), + meta(the_mint, false, false), + meta(system_program(), false, false), + ]; + self.run(data, metas).assert_success(); + } + + #[allow(clippy::too_many_arguments)] + fn init_reserve(&mut self, the_mint: Pubkey, reserve: Pubkey, vault: Pubkey, share: Pubkey, price: Pubkey) { + // 75% LTV, 80% liq threshold, 5% bonus, 50% close factor, kink 80%, + // 2% / 20% / 150% APR curve. + let config: [u16; 8] = [7_500, 8_000, 500, 5_000, 8_000, 200, 2_000, 15_000]; + let mut data = vec![1u8]; + for value in config { + data.extend_from_slice(&value.to_le_bytes()); + } + let metas = vec![ + meta(OWNER, true, true), + meta(self.market, false, false), + meta(reserve, true, false), + meta(the_mint, false, false), + meta(vault, true, false), + meta(share, true, false), + meta(price, false, false), + meta(token_program(), false, false), + meta(system_program(), false, false), + ]; + self.run(data, metas).assert_success(); + } + + fn setup_markets(&mut self) { + self.init_market(); + self.set_price(COLL_MINT, self.coll_price, dollars(1)); + self.set_price(BORROW_MINT, self.borrow_price, dollars(1)); + self.init_reserve(COLL_MINT, self.coll_reserve, self.coll_vault, self.coll_share_mint, self.coll_price); + self.init_reserve(BORROW_MINT, self.borrow_reserve, self.borrow_vault, self.borrow_share_mint, self.borrow_price); + } + + #[allow(clippy::too_many_arguments)] + fn deposit( + &mut self, + supplier: Pubkey, + reserve: Pubkey, + the_mint: Pubkey, + vault: Pubkey, + share: Pubkey, + supplier_liq: Pubkey, + supplier_share: Pubkey, + amount: u64, + ) -> quasar_svm::ExecutionResult { + let mut data = vec![3u8]; + data.extend_from_slice(&amount.to_le_bytes()); + let metas = vec![ + meta(supplier, true, true), + meta(reserve, true, false), + meta(the_mint, false, false), + meta(vault, true, false), + meta(share, true, false), + meta(supplier_liq, true, false), + meta(supplier_share, true, false), + meta(token_program(), false, false), + ]; + self.run(data, metas) + } + + fn redeem( + &mut self, + supplier_liq: Pubkey, + supplier_share: Pubkey, + shares: u64, + ) -> quasar_svm::ExecutionResult { + let mut data = vec![4u8]; + data.extend_from_slice(&shares.to_le_bytes()); + let metas = vec![ + meta(SUPPLIER, true, true), + meta(self.borrow_reserve, true, false), + meta(BORROW_MINT, false, false), + meta(self.borrow_vault, true, false), + meta(self.borrow_share_mint, true, false), + meta(supplier_liq, true, false), + meta(supplier_share, true, false), + meta(token_program(), false, false), + ]; + self.run(data, metas) + } + + fn init_obligation(&mut self) { + let metas = vec![ + meta(BORROWER, true, true), + meta(self.market, false, false), + meta(self.obligation, true, false), + meta(system_program(), false, false), + ]; + self.run(vec![5], metas).assert_success(); + } + + fn post_collateral(&mut self, shares: u64) -> quasar_svm::ExecutionResult { + let mut data = vec![6u8]; + data.extend_from_slice(&shares.to_le_bytes()); + let metas = vec![ + meta(BORROWER, true, true), + meta(self.market, false, false), + meta(self.obligation, true, false), + meta(self.coll_reserve, false, false), + meta(self.coll_share_mint, false, false), + meta(self.obligation_vault, true, false), + meta(BORROWER_COLL_SHARE, true, false), + meta(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false, false), + meta(token_program(), false, false), + meta(system_program(), false, false), + ]; + self.run(data, metas) + } + + fn borrow(&mut self, amount: u64) -> quasar_svm::ExecutionResult { + let mut data = vec![8u8]; + data.extend_from_slice(&amount.to_le_bytes()); + let metas = vec![ + meta(BORROWER, true, true), + meta(self.market, false, false), + meta(self.obligation, true, false), + meta(self.coll_reserve, true, false), + meta(self.coll_price, false, false), + meta(self.borrow_reserve, true, false), + meta(self.borrow_price, false, false), + meta(BORROW_MINT, false, false), + meta(self.borrow_vault, true, false), + meta(BORROWER_BORROW, true, false), + meta(token_program(), false, false), + ]; + self.run(data, metas) + } + + fn repay(&mut self, amount: u64) -> quasar_svm::ExecutionResult { + let mut data = vec![9u8]; + data.extend_from_slice(&amount.to_le_bytes()); + let metas = vec![ + meta(BORROWER, true, true), + meta(self.obligation, true, false), + meta(self.borrow_reserve, true, false), + meta(BORROW_MINT, false, false), + meta(self.borrow_vault, true, false), + meta(BORROWER_BORROW, true, false), + meta(token_program(), false, false), + ]; + self.run(data, metas) + } + + fn liquidate(&mut self, amount: u64) -> quasar_svm::ExecutionResult { + let mut data = vec![10u8]; + data.extend_from_slice(&amount.to_le_bytes()); + let metas = vec![ + meta(LIQUIDATOR, true, true), + meta(self.obligation, true, false), + meta(self.market, false, false), + meta(self.coll_reserve, true, false), + meta(self.coll_price, false, false), + meta(self.coll_share_mint, false, false), + meta(self.obligation_vault, true, false), + meta(LIQUIDATOR_COLL_SHARE, true, false), + meta(self.borrow_reserve, true, false), + meta(self.borrow_price, false, false), + meta(BORROW_MINT, false, false), + meta(self.borrow_vault, true, false), + meta(LIQUIDATOR_BORROW, true, false), + meta(token_program(), false, false), + ]; + self.run(data, metas) + } + + /// Supplier funds the borrow reserve; borrower posts 1000 units of collateral. + fn bootstrap_position(&mut self) { + self.setup_markets(); + self.deposit( + SUPPLIER, self.borrow_reserve, BORROW_MINT, self.borrow_vault, + self.borrow_share_mint, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1_000 * UNIT, + ) + .assert_success(); + self.deposit( + BORROWER, self.coll_reserve, COLL_MINT, self.coll_vault, + self.coll_share_mint, BORROWER_COLL, BORROWER_COLL_SHARE, 1_000 * UNIT, + ) + .assert_success(); + self.init_obligation(); + self.post_collateral(1_000 * UNIT).assert_success(); + } +} + +#[test] +fn supply_mints_shares_one_to_one_and_redeem_returns_liquidity() { + let mut world = World::new(); + world.setup_markets(); + + let result = world.deposit( + SUPPLIER, world.borrow_reserve, BORROW_MINT, world.borrow_vault, + world.borrow_share_mint, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1_000 * UNIT, + ); + result.assert_success(); + assert_eq!(balance(&result, SUPPLIER_BORROW_SHARE), 1_000 * UNIT, "first deposit mints 1:1"); + assert_eq!(balance(&result, SUPPLIER_BORROW), 0); + + let result = world.redeem(SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1_000 * UNIT); + result.assert_success(); + assert_eq!(balance(&result, SUPPLIER_BORROW), 1_000 * UNIT, "redeem returns liquidity"); + assert_eq!(balance(&result, SUPPLIER_BORROW_SHARE), 0); +} + +#[test] +fn borrow_up_to_ltv_succeeds_and_beyond_fails() { + let mut world = World::new(); + world.bootstrap_position(); + + // $1000 collateral, 75% LTV => borrow up to 750 units of the $1 borrow token. + let result = world.borrow(750 * UNIT); + result.assert_success(); + assert_eq!(balance(&result, BORROWER_BORROW), 750 * UNIT); + + // One unit more exceeds the allowed borrow value. + assert!(world.borrow(UNIT).is_err(), "borrowing past LTV must fail"); +} + +#[test] +fn repay_reduces_debt() { + let mut world = World::new(); + world.bootstrap_position(); + world.borrow(500 * UNIT).assert_success(); + + let result = world.repay(200 * UNIT); + result.assert_success(); + // Borrower spent 200 of the 500 borrowed. + assert_eq!(balance(&result, BORROWER_BORROW), 300 * UNIT); +} + +#[test] +fn interest_accrues_and_lifts_share_value() { + let mut world = World::new(); + world.bootstrap_position(); + world.borrow(500 * UNIT).assert_success(); + + // ~0.1 year passes; re-publish prices so feeds stay fresh. + world.svm.sysvars.warp_to_slot(7_884_000); + world.set_price(COLL_MINT, world.coll_price, dollars(1)); + world.set_price(BORROW_MINT, world.borrow_price, dollars(1)); + + // Supplier redeems 100 shares; interest on the 500 borrowed means each share + // is now worth more than one liquidity unit. + let result = world.redeem(SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 100 * UNIT); + result.assert_success(); + assert!( + balance(&result, SUPPLIER_BORROW) > 100 * UNIT, + "100 shares should redeem for more than 100 units after interest, got {}", + balance(&result, SUPPLIER_BORROW) + ); +} + +#[test] +fn unhealthy_position_is_liquidated_and_healthy_is_rejected() { + let mut world = World::new(); + world.bootstrap_position(); + world.borrow(700 * UNIT).assert_success(); + + // Healthy at $1 collateral ($1000 * 80% = $800 threshold > $700 debt). + assert!(world.liquidate(350 * UNIT).is_err(), "healthy obligation must not be liquidatable"); + + // Collateral price halves to $0.50: $500 collateral, $400 threshold < $700 debt. + world.set_price(COLL_MINT, world.coll_price, cents(50)); + + let result = world.liquidate(350 * UNIT); + result.assert_success(); + // Liquidator repaid 350 of the borrow token and seized collateral share tokens. + assert_eq!(balance(&result, LIQUIDATOR_BORROW), 650 * UNIT); + assert!( + balance(&result, LIQUIDATOR_COLL_SHARE) > 0, + "liquidator should receive seized collateral shares" + ); +} From 9bd4002316819f9f2b4e24afff0f670869cabbef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 20:58:38 +0000 Subject: [PATCH 4/6] 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 --- finance/lending/anchor/CHANGELOG.md | 9 ++ finance/lending/anchor/README.md | 38 ++++- .../anchor/programs/lending/src/errors.rs | 6 +- .../src/instructions/admin/set_price.rs | 28 ++-- .../borrow_obligation_liquidity.rs | 5 +- .../deposit_obligation_collateral.rs | 5 +- .../instructions/deposit_reserve_liquidity.rs | 4 +- .../src/instructions/liquidate_obligation.rs | 68 +++++---- .../instructions/redeem_reserve_collateral.rs | 4 +- .../src/instructions/refresh_obligation.rs | 18 ++- .../repay_obligation_liquidity.rs | 1 + .../withdraw_obligation_collateral.rs | 24 +++- .../anchor/programs/lending/src/math.rs | 17 +-- .../programs/lending/src/state/price_feed.rs | 10 +- .../programs/lending/src/state/reserve.rs | 23 ++- .../programs/lending/tests/common/mod.rs | 135 +++++++++++++----- .../lending/tests/test_liquidation.rs | 38 +++++ .../programs/lending/tests/test_security.rs | 76 ++++++++++ 18 files changed, 386 insertions(+), 123 deletions(-) create mode 100644 finance/lending/anchor/programs/lending/tests/test_security.rs diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 91e0dbb4..3c648ce8 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -15,3 +15,12 @@ Initial lending program: a Kamino/Solend-style borrow/lend market. - Rust + LiteSVM integration tests covering supply/redeem, borrow/repay, withdraw, interest accrual, liquidation, the share-inflation guard, and rounding/stale-input edge cases. +- Lending markets are isolation boundaries: every obligation handler rejects + reserves from another market (`MarketMismatch`). +- Price feed PDAs are seeded by their authority, so no signer can write or + pre-claim a feed another authority's reserves trust. +- Liquidation reads the close factor from the repay reserve, the bonus from the + collateral reserve, and rejects repayments whose seizure would exceed the + posted collateral (`LiquidationTooLarge`). +- Withdraw health checks round the removed borrow power up, so independent + rounding can never let a withdraw pass that an exact recompute would reject. diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 115684b6..76f4a074 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -69,6 +69,18 @@ Borrowing and withdrawing are gated by `allowed_borrow_value`; an obligation is liquidatable once `borrowed_value > unhealthy_borrow_value`. Collateral is valued rounding down and debt rounding up, so health is always judged conservatively. +Every handler that pairs an obligation with a reserve requires both to belong to +the same `LendingMarket` (`MarketMismatch` otherwise), so each market is an +isolation boundary: positions in one market can never be valued or settled +against reserves of another. + +In a liquidation, the close factor (how much of the borrow one call may repay) +comes from the **repay reserve**, because it is a property of the debt being +closed; the liquidation bonus comes from the **collateral reserve**, because it +prices the collateral being seized. A repayment whose seizure would exceed the +posted collateral fails with `LiquidationTooLarge` rather than silently seizing +less, which would make the liquidator overpay. + ### Fixed-point math All money math is integer-only `u128` — no floats, no fixed-point crates. Ratios @@ -82,11 +94,17 @@ round-trips. `PriceFeed` mirrors a Switchboard On-Demand pull feed: a signed mantissa, an exponent (`price = mantissa * 10^exponent`), and the slot the price was written. Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not wall-clock -time. The `set_price` handler writes the feed directly so the LiteSVM tests are +time. The feed PDA is seeded by `[b"price_feed", authority, mint]`, so a signer +can only ever write the feed derived from their own key — there is no shared +per-mint feed to claim first — and a reserve trusts exactly one feed: the +account its market owner passed to `init_reserve`. + +The `set_price` handler writes the feed directly so the LiteSVM tests are deterministic; in production a reserve points at the real Switchboard feed and the program decodes `PullFeedAccountData` (`price_mantissa = current_result.value`, -`exponent = -18`, `last_updated_slot = current_result.slot`) instead. Switchboard -is used rather than Pyth here for its lower compute cost. +`exponent = -18`, `last_updated_slot = current_result.slot`) instead, and should +also reject results whose confidence interval is too wide. Switchboard is used +rather than Pyth here for its lower compute cost. ### Custody @@ -95,6 +113,20 @@ per-obligation vault PDAs whose authority is the obligation PDA. The market owne can update reserve risk parameters (`update_reserve_config`) but has no path to move user funds — there is no admin withdrawal or escape hatch. +### Known limits + +- **Tokens with transfer fees are not supported.** The program uses + `token_interface`, so Token Extensions mints are accepted, but a transfer-fee + extension would make the vault receive less than the recorded deposit and the + accounting would overstate `available_liquidity`. Production protocols + whitelist mints; a market owner here must only create reserves for tokens + without transfer fees. +- **Reserve config changes act immediately.** Lowering a reserve's + `liquidation_threshold_bps` can make existing obligations liquidatable at + once; production governance phases such changes in. +- This is an example. Deploying any program that custodies funds calls for a + professional security audit first. + ### Instruction handlers Admin: `init_lending_market`, `init_reserve`, `update_reserve_config`, `set_price`. diff --git a/finance/lending/anchor/programs/lending/src/errors.rs b/finance/lending/anchor/programs/lending/src/errors.rs index f4624320..f07d965d 100644 --- a/finance/lending/anchor/programs/lending/src/errors.rs +++ b/finance/lending/anchor/programs/lending/src/errors.rs @@ -32,6 +32,8 @@ pub enum LendingError { ReserveNotFound, #[msg("A refresh account did not match the obligation's stored reserves")] InvalidObligationAccount, - #[msg("Signer is not authorized for this price feed")] - UnauthorizedPriceFeed, + #[msg("Reserve belongs to a different lending market than the obligation")] + MarketMismatch, + #[msg("Repay amount would seize more collateral than the obligation holds")] + LiquidationTooLarge, } diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs index e0c8344f..8e6428ad 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs @@ -2,33 +2,25 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::Mint; use crate::constants::PRICE_FEED_SEED; -use crate::errors::LendingError; use crate::state::PriceFeed; /// Test stand-in for a Switchboard On-Demand feed: writes a price directly so /// LiteSVM tests are deterministic. In production the reserve points at a real /// Switchboard feed instead and this handler is unused. +/// +/// The feed PDA is seeded by `[b"price_feed", authority, mint]`, so each +/// authority can only ever write its own feed — there is no shared per-mint +/// feed to race for. A reserve trusts exactly one feed account: the one the +/// market owner passed to `init_reserve`. pub fn handle_set_price( context: Context, price_mantissa: i128, exponent: i32, ) -> Result<()> { let feed = &mut context.accounts.price_feed; - - // On first creation the authority is unset (default Pubkey); claim it for - // the signer. On later updates only that authority may write. - if feed.authority == Pubkey::default() { - feed.authority = context.accounts.authority.key(); - feed.mint = context.accounts.mint.key(); - feed.bump = context.bumps.price_feed; - } else { - require_keys_eq!( - feed.authority, - context.accounts.authority.key(), - LendingError::UnauthorizedPriceFeed - ); - } - + feed.authority = context.accounts.authority.key(); + feed.mint = context.accounts.mint.key(); + feed.bump = context.bumps.price_feed; feed.price_mantissa = price_mantissa; feed.exponent = exponent; feed.last_updated_slot = Clock::get()?.slot; @@ -37,11 +29,13 @@ pub fn handle_set_price( #[derive(Accounts)] pub struct SetPrice<'info> { + // The authority is part of the seeds: a signer can only ever address (and + // therefore write) the feed derived from their own key. #[account( init_if_needed, payer = authority, space = PriceFeed::DISCRIMINATOR.len() + PriceFeed::INIT_SPACE, - seeds = [PRICE_FEED_SEED, mint.key().as_ref()], + seeds = [PRICE_FEED_SEED, authority.key().as_ref(), mint.key().as_ref()], bump, )] pub price_feed: Account<'info, PriceFeed>, diff --git a/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs index 70a4859d..ebd1b411 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs @@ -5,8 +5,8 @@ use anchor_spl::token_interface::{ use crate::constants::FIXED_POINT_SCALE; use crate::errors::LendingError; -use crate::math::{market_value, mul_div_ceil, reserve_signer_seeds, Rounding}; -use crate::state::{Obligation, PriceFeed, Reserve}; +use crate::math::{market_value, mul_div_ceil, Rounding}; +use crate::state::{reserve_signer_seeds, Obligation, PriceFeed, Reserve}; /// Borrow liquidity against the obligation's collateral. The new debt's value /// (rounded up) plus the existing debt must stay within the obligation's @@ -103,6 +103,7 @@ pub struct BorrowObligationLiquidity<'info> { has_one = liquidity_mint, has_one = liquidity_vault, has_one = price_feed, + constraint = reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch, )] pub reserve: Account<'info, Reserve>, diff --git a/finance/lending/anchor/programs/lending/src/instructions/deposit_obligation_collateral.rs b/finance/lending/anchor/programs/lending/src/instructions/deposit_obligation_collateral.rs index 01bc1610..a33e8bfa 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/deposit_obligation_collateral.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/deposit_obligation_collateral.rs @@ -51,7 +51,10 @@ pub struct DepositObligationCollateral<'info> { #[account(mut)] pub owner: Signer<'info>, - #[account(has_one = share_mint)] + #[account( + has_one = share_mint, + constraint = reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch, + )] pub reserve: Account<'info, Reserve>, pub share_mint: InterfaceAccount<'info, Mint>, diff --git a/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs index 7ae43702..3425497c 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs @@ -4,8 +4,8 @@ use anchor_spl::token_interface::{ }; use crate::errors::LendingError; -use crate::math::{mul_div_floor, reserve_signer_seeds}; -use crate::state::Reserve; +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 diff --git a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs index ec257b74..ee9668fd 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs @@ -3,16 +3,23 @@ use anchor_spl::token_interface::{ transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, }; -use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED}; +use crate::constants::{ + BPS_DENOMINATOR, FIXED_POINT_SCALE, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED, +}; use crate::errors::LendingError; use crate::math::{market_value, mul_div_ceil, mul_div_floor, value_to_amount, Rounding}; use crate::state::{Obligation, PriceFeed, Reserve}; -/// Repay part of an unhealthy obligation's debt and seize a matching amount of -/// its collateral share tokens plus a bonus. A single liquidation may repay at -/// most the collateral reserve's close factor of the borrow. The seized -/// collateral is valued at the borrow repaid plus the liquidation bonus, all -/// rounded toward the borrower so the obligation is never over-seized. +/// Repay part of an unhealthy obligation's debt and seize collateral share +/// tokens worth the repayment plus the liquidation bonus. +/// +/// The close factor caps how much of the borrow one call may repay; it comes +/// from the repay (borrow) reserve because it is a property of the debt being +/// closed. The liquidation bonus comes from the collateral reserve because it +/// prices the collateral being seized. If the requested repayment would seize +/// more collateral than the obligation holds, the call fails with +/// `LiquidationTooLarge` — silently capping the seizure would make the +/// liquidator pay full price for less collateral. /// /// Self-liquidation (the owner liquidating their own position) is not blocked: /// it is only possible while unhealthy and is economically pointless, matching @@ -28,44 +35,47 @@ pub fn handle_liquidate_obligation( context.accounts.repay_reserve.require_refreshed()?; context.accounts.collateral_reserve.require_refreshed()?; + let obligation = &context.accounts.obligation; + let repay_reserve = &context.accounts.repay_reserve; + let collateral_reserve = &context.accounts.collateral_reserve; + require!( - context.accounts.obligation.borrowed_value > context.accounts.obligation.unhealthy_borrow_value, + obligation.borrowed_value > obligation.unhealthy_borrow_value, LendingError::ObligationHealthy ); - let repay_reserve_key = context.accounts.repay_reserve.key(); - let collateral_reserve_key = context.accounts.collateral_reserve.key(); let repay_price = context.accounts.repay_price_feed.price_scaled(slot)?; let collateral_price = context.accounts.collateral_price_feed.price_scaled(slot)?; - let borrow_index = context.accounts.obligation.find_borrow(repay_reserve_key)?; - let collateral_index = context.accounts.obligation.find_collateral(collateral_reserve_key)?; - let borrowed_scaled = context.accounts.obligation.borrows[borrow_index].borrowed_scaled; - let deposited_shares = context.accounts.obligation.deposits[collateral_index].deposited_shares; + let borrow_index = obligation.find_borrow(repay_reserve.key())?; + let collateral_index = obligation.find_collateral(collateral_reserve.key())?; + let borrowed_scaled = obligation.borrows[borrow_index].borrowed_scaled; + let deposited_shares = obligation.deposits[collateral_index].deposited_shares; // How much debt this liquidation repays, capped by the close factor. - let interest_index = context.accounts.repay_reserve.cumulative_borrow_rate_index; + let interest_index = repay_reserve.cumulative_borrow_rate_index; let debt_now = mul_div_ceil(borrowed_scaled, interest_index, FIXED_POINT_SCALE)?; let debt_now = u64::try_from(debt_now).map_err(|_| LendingError::MathOverflow)?; let max_repay = mul_div_floor( debt_now as u128, - context.accounts.collateral_reserve.config.close_factor_bps as u128, + repay_reserve.config.close_factor_bps as u128, BPS_DENOMINATOR, )?; let repay = liquidity_amount.min(u64::try_from(max_repay).map_err(|_| LendingError::MathOverflow)?); require!(repay > 0, LendingError::ZeroAmount); // Collateral to seize: value of the repayment plus the bonus, converted into - // the collateral token and then into share tokens. Every step rounds down. + // the collateral token and then into share tokens. Every step rounds down, + // toward the borrower, so the obligation is never over-seized by rounding. let repay_value = market_value( repay, - context.accounts.repay_reserve.liquidity_decimals, + repay_reserve.liquidity_decimals, repay_price, Rounding::Down, )?; let bonus_value = mul_div_floor( repay_value, - context.accounts.collateral_reserve.config.liquidation_bonus_bps as u128, + collateral_reserve.config.liquidation_bonus_bps as u128, BPS_DENOMINATOR, )?; let seize_value = repay_value @@ -73,19 +83,21 @@ pub fn handle_liquidate_obligation( .ok_or(LendingError::MathOverflow)?; let seize_liquidity = value_to_amount( seize_value, - context.accounts.collateral_reserve.liquidity_decimals, + collateral_reserve.liquidity_decimals, collateral_price, Rounding::Down, )?; let seize_shares = mul_div_floor( seize_liquidity as u128, - context.accounts.collateral_reserve.share_mint_supply as u128, - context.accounts.collateral_reserve.total_liquidity()?.max(1), + collateral_reserve.share_mint_supply as u128, + collateral_reserve.total_liquidity()?.max(1), )?; - let seize_shares = u64::try_from(seize_shares) - .map_err(|_| LendingError::MathOverflow)? - .min(deposited_shares); + let seize_shares = u64::try_from(seize_shares).map_err(|_| LendingError::MathOverflow)?; require!(seize_shares > 0, LendingError::ZeroAmount); + require!( + seize_shares <= deposited_shares, + LendingError::LiquidationTooLarge + ); let scaled_removed = mul_div_floor(repay as u128, FIXED_POINT_SCALE, interest_index)?.min(borrowed_scaled); @@ -166,9 +178,15 @@ pub struct LiquidateObligation<'info> { pub liquidator: Signer<'info>, - #[account(mut)] + #[account( + mut, + constraint = repay_reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch, + )] pub repay_reserve: Box>, + #[account( + constraint = collateral_reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch, + )] pub collateral_reserve: Box>, #[account(address = repay_reserve.price_feed)] diff --git a/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs b/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs index 6d0309b0..459b56cf 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs @@ -4,8 +4,8 @@ use anchor_spl::token_interface::{ }; use crate::errors::LendingError; -use crate::math::{mul_div_floor, reserve_signer_seeds}; -use crate::state::Reserve; +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 diff --git a/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs index 440c5096..2333dad9 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs @@ -18,6 +18,7 @@ use crate::state::{Obligation, PriceFeed, Reserve}; pub fn handle_refresh_obligation(context: Context) -> Result<()> { let slot = Clock::get()?.slot; let obligation = &mut context.accounts.obligation; + let lending_market = obligation.lending_market; let accounts = context.remaining_accounts; let mut cursor = 0usize; @@ -26,7 +27,8 @@ pub fn handle_refresh_obligation(context: Context) -> Result< let mut unhealthy_borrow_value: u128 = 0; for collateral in obligation.deposits.iter_mut() { - let (reserve, price_scaled) = read_pair(accounts, &mut cursor, collateral.reserve, slot)?; + let (reserve, price_scaled) = + read_pair(accounts, &mut cursor, collateral.reserve, lending_market, slot)?; let liquidity = mul_div_floor( collateral.deposited_shares as u128, @@ -58,7 +60,8 @@ pub fn handle_refresh_obligation(context: Context) -> Result< let mut borrowed_value: u128 = 0; for borrow in obligation.borrows.iter_mut() { - let (reserve, price_scaled) = read_pair(accounts, &mut cursor, borrow.reserve, slot)?; + let (reserve, price_scaled) = + read_pair(accounts, &mut cursor, borrow.reserve, lending_market, slot)?; let debt = mul_div_ceil( borrow.borrowed_scaled, @@ -89,12 +92,14 @@ pub fn handle_refresh_obligation(context: Context) -> Result< } /// Read the next `[reserve, price_feed]` pair from `remaining_accounts`, -/// checking it matches the obligation's stored reserve and that both the -/// reserve (refreshed this slot) and the price (fresh) are usable. +/// checking it matches the obligation's stored reserve, belongs to the +/// obligation's lending market, and that both the reserve (refreshed this +/// slot) and the price (fresh) are usable. fn read_pair<'a, 'info>( accounts: &'a [AccountInfo<'info>], cursor: &mut usize, expected_reserve: Pubkey, + lending_market: Pubkey, slot: u64, ) -> Result<(Reserve, u128)> where @@ -114,6 +119,11 @@ where LendingError::InvalidObligationAccount ); let reserve = Account::::try_from(reserve_info)?; + require_keys_eq!( + reserve.lending_market, + lending_market, + LendingError::MarketMismatch + ); reserve.require_refreshed()?; require_keys_eq!( diff --git a/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs index acce54e1..ecf671f9 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs @@ -82,6 +82,7 @@ pub struct RepayObligationLiquidity<'info> { mut, has_one = liquidity_mint, has_one = liquidity_vault, + constraint = reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch, )] pub reserve: Account<'info, Reserve>, diff --git a/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs b/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs index c272aac3..a38860b8 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs @@ -5,7 +5,7 @@ use anchor_spl::token_interface::{ use crate::constants::{BPS_DENOMINATOR, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED}; use crate::errors::LendingError; -use crate::math::{market_value, mul_div_floor, Rounding}; +use crate::math::{market_value, mul_div_ceil, Rounding}; use crate::state::{Obligation, PriceFeed, Reserve}; /// Withdraw posted share-token collateral, but only as long as the obligation @@ -32,7 +32,11 @@ pub fn handle_withdraw_obligation_collateral( ); // Value of the collateral being removed, and the borrow power it backed. - let removed_liquidity = mul_div_floor( + // Every step rounds UP: subtracting an over-estimate of the removed borrow + // power guarantees the resulting allowance is never higher than a full + // recompute would give, so independent flooring can't let a withdraw + // squeak past the health check by a rounding sub-unit. + let removed_liquidity = mul_div_ceil( share_amount as u128, reserve.total_liquidity()?, (reserve.share_mint_supply as u128).max(1), @@ -42,17 +46,19 @@ pub fn handle_withdraw_obligation_collateral( removed_liquidity, reserve.liquidity_decimals, price_scaled, - Rounding::Down, + Rounding::Up, )?; - let removed_allowed = mul_div_floor( + let removed_allowed = mul_div_ceil( removed_value, reserve.config.loan_to_value_bps as u128, BPS_DENOMINATOR, )?; + // saturating_sub is correct here (and not balance math): the ceil-rounded + // removal can exceed the floor-cached total by a sub-unit when withdrawing + // everything, and zero remaining allowance is the conservative answer. let new_allowed_borrow_value = obligation .allowed_borrow_value - .checked_sub(removed_allowed) - .ok_or(LendingError::MathOverflow)?; + .saturating_sub(removed_allowed); require!( obligation.borrowed_value <= new_allowed_borrow_value, LendingError::WithdrawTooLarge @@ -102,7 +108,11 @@ pub struct WithdrawObligationCollateral<'info> { pub owner: Signer<'info>, - #[account(has_one = share_mint, has_one = price_feed)] + #[account( + has_one = share_mint, + has_one = price_feed, + constraint = reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch, + )] pub reserve: Account<'info, Reserve>, pub price_feed: Account<'info, PriceFeed>, diff --git a/finance/lending/anchor/programs/lending/src/math.rs b/finance/lending/anchor/programs/lending/src/math.rs index 7731fec4..26dc107d 100644 --- a/finance/lending/anchor/programs/lending/src/math.rs +++ b/finance/lending/anchor/programs/lending/src/math.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -use crate::constants::{FIXED_POINT_SCALE_DECIMALS, RESERVE_SEED}; +use crate::constants::FIXED_POINT_SCALE_DECIMALS; use crate::errors::LendingError; /// Which way to break ties when a division truncates. Deposits/redeems and @@ -99,18 +99,3 @@ pub fn price_mantissa_to_scaled(mantissa: u128, exponent: i32) -> Result { .ok_or(LendingError::MathOverflow)?) } } - -/// Signer seeds for a reserve PDA, which is the authority over its liquidity -/// vault and the mint authority of its share token. -pub fn reserve_signer_seeds<'a>( - lending_market: &'a Pubkey, - liquidity_mint: &'a Pubkey, - bump: &'a [u8; 1], -) -> [&'a [u8]; 4] { - [ - RESERVE_SEED, - lending_market.as_ref(), - liquidity_mint.as_ref(), - bump, - ] -} diff --git a/finance/lending/anchor/programs/lending/src/state/price_feed.rs b/finance/lending/anchor/programs/lending/src/state/price_feed.rs index 52aa8978..ba876797 100644 --- a/finance/lending/anchor/programs/lending/src/state/price_feed.rs +++ b/finance/lending/anchor/programs/lending/src/state/price_feed.rs @@ -5,6 +5,9 @@ use crate::errors::LendingError; use crate::math::price_mantissa_to_scaled; /// A price for one token, denominated in the market's quote currency. +/// PDA seeds: `[b"price_feed", authority, mint]` — the writer is part of the +/// address, so no two authorities can contend for the same feed account, and a +/// reserve trusts exactly the feed its market owner selected at `init_reserve`. /// /// The layout mirrors a Switchboard On-Demand pull feed: a signed mantissa plus /// an exponent (`price = price_mantissa * 10^exponent`) and the slot the value @@ -13,6 +16,8 @@ use crate::math::price_mantissa_to_scaled; /// (`PullFeedAccountData`): `price_mantissa = current_result.value`, /// `exponent = -18`, `last_updated_slot = current_result.slot`. Here the /// `set_price` handler writes it directly so LiteSVM tests are deterministic. +/// A production read should also reject results whose confidence interval is +/// too wide; this stand-in has no confidence field to check. #[account] #[derive(InitSpace)] pub struct PriceFeed { @@ -24,8 +29,9 @@ pub struct PriceFeed { pub last_updated_slot: u64, - /// Account permitted to call `set_price`. In production this field is unused - /// because the feed is owned by Switchboard, not this program. + /// The signer whose key is in this feed's PDA seeds; the only account that + /// can write it. In production this field does not exist — the feed is + /// owned and written by Switchboard. pub authority: Pubkey, pub bump: u8, diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index 2880ad23..e5199977 100644 --- a/finance/lending/anchor/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -1,9 +1,24 @@ use anchor_lang::prelude::*; -use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, SLOTS_PER_YEAR}; +use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED, SLOTS_PER_YEAR}; use crate::errors::LendingError; use crate::math::{mul_div_ceil, mul_div_floor}; +/// Signer seeds for a reserve PDA, which is the authority over its liquidity +/// vault and the mint authority of its share token. +pub fn reserve_signer_seeds<'a>( + lending_market: &'a Pubkey, + liquidity_mint: &'a Pubkey, + bump: &'a [u8; 1], +) -> [&'a [u8]; 4] { + [ + RESERVE_SEED, + lending_market.as_ref(), + liquidity_mint.as_ref(), + bump, + ] +} + /// One asset's lending pool. Suppliers deposit `liquidity_mint` tokens into /// `liquidity_vault` and receive share tokens (`share_mint`); the share-to- /// liquidity exchange rate rises as borrowers pay interest. Borrowers draw @@ -33,7 +48,11 @@ pub struct Reserve { pub available_liquidity: u64, /// Outstanding share-token supply, tracked here so valuations need only the - /// reserve account (not the mint) to convert shares to liquidity. + /// reserve account (not the mint) to convert shares to liquidity. A holder + /// 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. pub share_mint_supply: u64, /// Total borrowed principal, scaled so that the live debt is diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index 4204b4a0..a4d73c65 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -119,54 +119,52 @@ impl Env { self.svm.get_sysvar::().slot } - /// Advance time so interest accrues and blockhashes differ. - pub fn warp_slots(&mut self, slots: u64) { - let target = self.current_slot() + slots; - self.svm.warp_to_slot(target); - self.svm.expire_blockhash(); - } - - pub fn set_price(&mut self, mint: Pubkey, price_mantissa: i128) { - let price_feed = pda(&[PRICE_FEED_SEED, mint.as_ref()]); + /// Create a second lending market owned by `market_owner`, for tests that + /// exercise cross-market isolation. + pub fn init_market_for(&mut self, market_owner: &Keypair) -> Pubkey { + let env_owner = self.owner.insecure_clone(); + let quote_mint = create_token_mint(&mut self.svm, &env_owner, 6, None).unwrap(); + let market = pda(&[LENDING_MARKET_SEED, market_owner.pubkey().as_ref()]); let instruction = Instruction { program_id: lending::id(), - accounts: lending::accounts::SetPrice { - price_feed, - authority: self.owner.pubkey(), - mint, + accounts: lending::accounts::InitLendingMarket { + lending_market: market, + owner: market_owner.pubkey(), + quote_currency_mint: quote_mint, system_program: system_program::id(), } .to_account_metas(None), - data: lending::instruction::SetPrice { - price_mantissa, - exponent: PRICE_EXPONENT, - } - .data(), + data: lending::instruction::InitLendingMarket {}.data(), }; - let owner = self.owner.insecure_clone(); - send(&mut self.svm, vec![instruction], &[&owner], &owner.pubkey()).unwrap(); + send(&mut self.svm, vec![instruction], &[market_owner], &market_owner.pubkey()).unwrap(); + market } - pub fn add_reserve( + /// Add a reserve to a market other than the default one. The mint and price + /// feed are still created/written by the env owner (a reserve trusts + /// whichever feed its market owner registers; the writer need not match). + pub fn add_reserve_to( &mut self, + market_owner: &Keypair, + market: Pubkey, decimals: u8, price_mantissa: i128, config: ReserveConfig, ) -> ReserveHandle { - let owner = self.owner.insecure_clone(); - let mint = create_token_mint(&mut self.svm, &owner, decimals, None).unwrap(); + let env_owner = self.owner.insecure_clone(); + let mint = create_token_mint(&mut self.svm, &env_owner, decimals, None).unwrap(); self.set_price(mint, price_mantissa); - let reserve = pda(&[RESERVE_SEED, self.market.as_ref(), mint.as_ref()]); + let reserve = pda(&[RESERVE_SEED, market.as_ref(), mint.as_ref()]); let share_mint = pda(&[SHARE_MINT_SEED, reserve.as_ref()]); let liquidity_vault = pda(&[LIQUIDITY_VAULT_SEED, reserve.as_ref()]); - let price_feed = pda(&[PRICE_FEED_SEED, mint.as_ref()]); + let price_feed = self.price_feed_address(mint); let instruction = Instruction { program_id: lending::id(), accounts: lending::accounts::InitReserve { - lending_market: self.market, - owner: owner.pubkey(), + lending_market: market, + owner: market_owner.pubkey(), reserve, liquidity_mint: mint, liquidity_vault, @@ -178,7 +176,7 @@ impl Env { .to_account_metas(None), data: lending::instruction::InitReserve { config }.data(), }; - send(&mut self.svm, vec![instruction], &[&owner], &owner.pubkey()).unwrap(); + send(&mut self.svm, vec![instruction], &[market_owner], &market_owner.pubkey()).unwrap(); ReserveHandle { mint, @@ -190,6 +188,51 @@ impl Env { } } + /// Advance time so interest accrues and blockhashes differ. + pub fn warp_slots(&mut self, slots: u64) { + let target = self.current_slot() + slots; + self.svm.warp_to_slot(target); + self.svm.expire_blockhash(); + } + + /// The feed PDA the market owner writes for `mint`: seeded by the owner's + /// key, so it is the feed `add_reserve` registers reserves against. + pub fn price_feed_address(&self, mint: Pubkey) -> Pubkey { + pda(&[PRICE_FEED_SEED, self.owner.pubkey().as_ref(), mint.as_ref()]) + } + + pub fn set_price(&mut self, mint: Pubkey, price_mantissa: i128) { + let price_feed = self.price_feed_address(mint); + let instruction = Instruction { + program_id: lending::id(), + accounts: lending::accounts::SetPrice { + price_feed, + authority: self.owner.pubkey(), + mint, + system_program: system_program::id(), + } + .to_account_metas(None), + data: lending::instruction::SetPrice { + price_mantissa, + exponent: PRICE_EXPONENT, + } + .data(), + }; + let owner = self.owner.insecure_clone(); + send(&mut self.svm, vec![instruction], &[&owner], &owner.pubkey()).unwrap(); + } + + pub fn add_reserve( + &mut self, + decimals: u8, + price_mantissa: i128, + config: ReserveConfig, + ) -> ReserveHandle { + let owner = self.owner.insecure_clone(); + let market = self.market; + self.add_reserve_to(&owner, market, decimals, price_mantissa, config) + } + pub fn try_update_config( &mut self, handle: &ReserveHandle, @@ -332,13 +375,13 @@ impl Env { ]) } - pub fn post_collateral( + pub fn try_post_collateral( &mut self, user: &Keypair, obligation: Pubkey, handle: &ReserveHandle, share_amount: u64, - ) { + ) -> Result<(), String> { let user_share = ata(&user.pubkey(), &handle.share_mint); let vault = self.obligation_share_vault(handle, obligation); let instruction = Instruction { @@ -356,7 +399,18 @@ impl Env { .to_account_metas(None), data: lending::instruction::DepositObligationCollateral { share_amount }.data(), }; - send(&mut self.svm, vec![instruction], &[user], &user.pubkey()).unwrap(); + send(&mut self.svm, vec![instruction], &[user], &user.pubkey()) + } + + pub fn post_collateral( + &mut self, + user: &Keypair, + obligation: Pubkey, + handle: &ReserveHandle, + share_amount: u64, + ) { + self.try_post_collateral(user, obligation, handle, share_amount) + .unwrap() } fn refresh_obligation_ix( @@ -537,13 +591,18 @@ impl Env { amount: u64, ) -> Result<(), String> { let repay_source = ata(&liquidator.pubkey(), &repay.mint); - let collateral_dest = create_associated_token_account( - &mut self.svm, - &liquidator.pubkey(), - &collateral.share_mint, - liquidator, - ) - .unwrap(); + // Create the destination ATA only on the first call, so a test can + // attempt several liquidations. + let collateral_dest = ata(&liquidator.pubkey(), &collateral.share_mint); + if self.svm.get_account(&collateral_dest).is_none() { + create_associated_token_account( + &mut self.svm, + &liquidator.pubkey(), + &collateral.share_mint, + liquidator, + ) + .unwrap(); + } let vault = self.obligation_share_vault(collateral, obligation); let mut all: Vec<&ReserveHandle> = deposit_reserves.to_vec(); diff --git a/finance/lending/anchor/programs/lending/tests/test_liquidation.rs b/finance/lending/anchor/programs/lending/tests/test_liquidation.rs index 66284d85..57c99e8a 100644 --- a/finance/lending/anchor/programs/lending/tests/test_liquidation.rs +++ b/finance/lending/anchor/programs/lending/tests/test_liquidation.rs @@ -98,3 +98,41 @@ fn unhealthy_obligation_liquidated_with_bonus_capped_by_close_factor() { let obligation_state = env.obligation(obligation); assert_eq!(obligation_state.deposits[0].deposited_shares, 1_000_000_000 - 459_375_000); } + +/// A repayment whose seizure would exceed the posted collateral is rejected +/// rather than silently capped — silently capping would make the liquidator +/// pay full price for less collateral. A smaller repayment still works. +#[test] +fn over_seizing_liquidation_rejected_smaller_succeeds() { + let (mut env, collateral, borrow, _borrower, obligation, liquidator) = setup(); + + // Collateral crashes to $0.10: $100 of collateral against $700 of debt. + // The close-factor max repay ($350, plus 5% bonus => $367.50 of collateral) + // would seize far more than the $100 posted. + env.set_price(collateral.mint, cents(10)); + + let over_seize = env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 350_000_000, + ); + assert!(over_seize.unwrap_err().contains("LiquidationTooLarge")); + + // Repaying $50 seizes $52.50 of collateral = 525 units at $0.10 — fits. + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 50_000_000, + ) + .unwrap(); + let liquidator_collateral_account = ata(&liquidator.pubkey(), &collateral.share_mint); + assert_eq!(env.token_balance(liquidator_collateral_account), 525_000_000); +} diff --git a/finance/lending/anchor/programs/lending/tests/test_security.rs b/finance/lending/anchor/programs/lending/tests/test_security.rs new file mode 100644 index 00000000..89466494 --- /dev/null +++ b/finance/lending/anchor/programs/lending/tests/test_security.rs @@ -0,0 +1,76 @@ +mod common; + +use anchor_lang::{ + solana_program::{instruction::Instruction, system_program}, + InstructionData, ToAccountMetas, +}; +use common::{default_config, dollars, Env}; +use solana_signer::Signer; + +/// A reserve from one lending market cannot be used with an obligation from +/// another: lending markets are isolation boundaries. +#[test] +fn cross_market_reserve_is_rejected() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + + // A second market with its own reserve. + let other_owner = env.create_user(); + let other_market = env.init_market_for(&other_owner); + let foreign_reserve = + env.add_reserve_to(&other_owner, other_market, 6, dollars(1), default_config()); + + // A borrower set up in the FIRST market. + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.init_obligation(&borrower); + + // Posting collateral via the second market's reserve must fail before any + // token movement. + env.fund(&borrower, foreign_reserve.share_mint, 0); // create the share ATA + let result = env.try_post_collateral(&borrower, obligation, &foreign_reserve, 1); + assert!( + result.unwrap_err().contains("MarketMismatch"), + "a reserve from another lending market must be rejected" + ); +} + +/// The price feed PDA is seeded by its authority, so no signer can write (or +/// pre-claim) the feed another authority's reserves trust. +#[test] +fn foreign_signer_cannot_write_owner_price_feed() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, dollars(1), default_config()); + + let attacker = env.create_user(); + let owner_feed = env.price_feed_address(usdc.mint); + + // The attacker targets the owner's feed address while signing as themself. + // The seeds [b"price_feed", authority, mint] cannot match, so this fails. + let instruction = Instruction { + program_id: lending::id(), + accounts: lending::accounts::SetPrice { + price_feed: owner_feed, + authority: attacker.pubkey(), + mint: usdc.mint, + system_program: system_program::id(), + } + .to_account_metas(None), + data: lending::instruction::SetPrice { + price_mantissa: common::dollars(1_000_000), // an absurd price + exponent: common::PRICE_EXPONENT, + } + .data(), + }; + let result = solana_kite::send_transaction_from_instructions( + &mut env.svm, + vec![instruction], + &[&attacker], + &attacker.pubkey(), + ); + assert!( + result.is_err(), + "only the authority in a feed's seeds may write that feed" + ); +} From 2e8e8100c1d825e7dbe1d2d391ba63914aca31d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 21:05:44 +0000 Subject: [PATCH 5/6] Mirror security hardening into the Quasar lending port Applies the same fixes as the Anchor version: - Price feed PDAs seeded [b"price_feed", authority, mint]; init_reserve binds each reserve to the feed written by its market owner, removing the first-caller-claims race and the claim branch in set_price. - deposit_obligation_collateral now requires the reserve to belong to the obligation's lending market (the one handler that was missing the check; the others already enforce it via has_one or stored-key equality). - Liquidation reads the close factor from the borrow reserve (a property of the debt) and rejects repayments whose seizure would exceed posted collateral (LiquidationTooLarge) instead of silently capping. https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9 --- finance/lending/quasar/CHANGELOG.md | 5 +++++ finance/lending/quasar/README.md | 12 ++++++++++-- finance/lending/quasar/src/error.rs | 1 + .../lending/quasar/src/instructions/admin.rs | 16 +++++++--------- .../lending/quasar/src/instructions/position.rs | 17 +++++++++++------ finance/lending/quasar/src/state.rs | 10 ++++++---- finance/lending/quasar/src/tests.rs | 5 +++-- 7 files changed, 43 insertions(+), 23 deletions(-) diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index 1e804dad..d52a8d03 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -14,3 +14,8 @@ Initial Quasar port of the Kamino/Solend-style borrow/lend program. - Switchboard-On-Demand-shaped price feed with a `set_price` test writer. - quasar-svm integration tests covering supply/redeem, borrow/repay, interest accrual, and liquidation (including the healthy-rejection path). +- Price feed PDAs are seeded by their authority, so no signer can write or + pre-claim a feed another authority's reserves trust. +- Liquidation reads the close factor from the borrow reserve, and rejects + repayments whose seizure would exceed posted collateral + (`LiquidationTooLarge`). diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index a8adeb03..8c624583 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -38,8 +38,16 @@ Everything else mirrors the Anchor version. deposited share amount, plus the borrow reserve and scaled debt. PDA: `["obligation", market, owner]`. - **`PriceFeed`** — a Switchboard-On-Demand-shaped price (`mantissa * 10^exponent` - + slot). `set_price` writes it directly for deterministic tests; in production a - reserve points at the real Switchboard feed. Freshness is checked in slots. + + slot). PDA: `["price_feed", authority, mint]` — the writer is part of the + address, so no signer can write or pre-claim another authority's feed, and each + reserve is bound to the feed its market owner registered. `set_price` writes it + directly for deterministic tests; in production a reserve points at the real + Switchboard feed. Freshness is checked in slots. +- **Liquidation** — the close factor (max fraction of the debt one call repays) + comes from the borrow reserve; the bonus from the collateral reserve. A + repayment whose seizure would exceed the posted collateral fails with + `LiquidationTooLarge` rather than silently seizing less, which would make the + liquidator overpay. - **Share tokens** — supplying mints them, redeeming burns them; the exchange rate `total_liquidity / share_supply` rises as borrowers pay interest. `available_liquidity` (not the vault's raw balance) is the source of truth, so a diff --git a/finance/lending/quasar/src/error.rs b/finance/lending/quasar/src/error.rs index 45a11189..110afb3e 100644 --- a/finance/lending/quasar/src/error.rs +++ b/finance/lending/quasar/src/error.rs @@ -15,4 +15,5 @@ pub enum LendingError { WithdrawTooLarge, ObligationHealthy, WrongReserve, + LiquidationTooLarge, } diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index b8f41cf5..83e4b2cf 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -1,7 +1,6 @@ use { crate::{ constants::{MINT_SPACE, TOKEN_ACCOUNT_SPACE}, - error::LendingError, logic::now, math::validate_config, state::{ @@ -58,7 +57,9 @@ pub struct InitReserve { /// Created and initialized as a share-token mint (authority = reserve) in the handler. #[account(mut, address = ShareMintPda::seeds(reserve.address()))] pub share_mint: UncheckedAccount, - #[account(address = PriceFeed::seeds(liquidity_mint.address()))] + // The reserve trusts the feed written by the market owner: feed PDAs are + // seeded by their authority, so this binds the reserve to the owner's feed. + #[account(address = PriceFeed::seeds(owner.address(), liquidity_mint.address()))] pub price_feed: Account, pub token_program: Program, pub system_program: Program, @@ -178,7 +179,10 @@ impl InitReserve { pub struct SetPrice { #[account(mut)] pub authority: Signer, - #[account(init(idempotent), payer = authority, address = PriceFeed::seeds(mint.address()))] + // The authority is part of the seeds: a signer can only ever address (and + // therefore write) the feed derived from their own key, so there is no + // shared per-mint feed to claim first. + #[account(init(idempotent), payer = authority, address = PriceFeed::seeds(authority.address(), mint.address()))] pub price_feed: Account, pub mint: Account, pub system_program: Program, @@ -192,12 +196,6 @@ impl SetPrice { exponent: i32, bumps: &SetPriceBumps, ) -> Result<(), ProgramError> { - // On first creation the stored authority is the zero address; claim it. - // Afterwards only that authority may update the feed. - let existing = self.price_feed.authority; - if existing != Address::default() { - require_keys_eq!(existing, *self.authority.address(), LendingError::InvalidConfig); - } self.price_feed.set_inner(PriceFeedInner { mint: *self.mint.address(), price_mantissa, diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs index 008b4a81..96e4fd06 100644 --- a/finance/lending/quasar/src/instructions/position.rs +++ b/finance/lending/quasar/src/instructions/position.rs @@ -67,7 +67,7 @@ pub struct DepositObligationCollateral { pub lending_market: Account, #[account(mut, has_one(owner), has_one(lending_market), address = Obligation::seeds(lending_market.address(), owner.address()))] pub obligation: Account, - #[account(has_one(share_mint))] + #[account(has_one(share_mint), has_one(lending_market))] pub reserve: Account, pub share_mint: Account, #[account( @@ -497,8 +497,9 @@ impl LiquidateObligation { let debt_value = market_value(debt, borrow.liquidity_decimals, borrow_price, Rounding::Up)?; require!(debt_value > unhealthy_threshold, LendingError::ObligationHealthy); - // Repay capped by the close factor. - let max_repay = mul_div_floor(debt as u128, collateral.close_factor_bps as u128, BPS_DENOMINATOR)?; + // Repay capped by the close factor — taken from the borrow reserve + // because it is a property of the debt being closed. + let max_repay = mul_div_floor(debt as u128, borrow.close_factor_bps as u128, BPS_DENOMINATOR)?; let repay = amount.min(u64::try_from(max_repay).map_err(|_| LendingError::MathOverflow)?); require!(repay > 0, LendingError::ZeroAmount); @@ -512,10 +513,14 @@ impl LiquidateObligation { collateral.share_mint_supply as u128, collateral_total.max(1), )?; - let seize_shares = u64::try_from(seize_shares) - .map_err(|_| LendingError::MathOverflow)? - .min(obligation.deposited_shares); + let seize_shares = u64::try_from(seize_shares).map_err(|_| LendingError::MathOverflow)?; require!(seize_shares > 0, LendingError::ZeroAmount); + // Reject rather than silently seize less: a capped seizure would make + // the liquidator pay full price for less collateral. + require!( + seize_shares <= obligation.deposited_shares, + LendingError::LiquidationTooLarge + ); let scaled_removed = mul_div_floor(repay as u128, SCALE, borrow.cumulative_borrow_rate_index)? .min(obligation.borrowed_scaled); diff --git a/finance/lending/quasar/src/state.rs b/finance/lending/quasar/src/state.rs index 69188285..04c3bcf9 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -55,11 +55,13 @@ pub struct Obligation { pub bump: u8, } -/// Switchboard-On-Demand-shaped price feed. PDA: `["price_feed", mint]`. -/// `price = price_mantissa * 10^exponent`; freshness is checked in slots. -/// In production this account would be the real Switchboard feed. +/// Switchboard-On-Demand-shaped price feed. PDA: `["price_feed", authority, mint]` +/// — the writer is part of the address, so no two authorities can contend for +/// the same feed, and a reserve trusts exactly the feed its market owner passed +/// to `init_reserve`. `price = price_mantissa * 10^exponent`; freshness is +/// checked in slots. In production this account would be the real Switchboard feed. #[account(discriminator = 4, set_inner)] -#[seeds(b"price_feed", mint: Address)] +#[seeds(b"price_feed", authority: Address, mint: Address)] pub struct PriceFeed { pub mint: Address, pub price_mantissa: i128, diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index 42eb7691..3a522997 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -130,8 +130,9 @@ impl World { let (borrow_vault, _) = pda(&[b"liquidity_vault", borrow_reserve.as_ref()]); let (coll_share_mint, _) = pda(&[b"share_mint", coll_reserve.as_ref()]); let (borrow_share_mint, _) = pda(&[b"share_mint", borrow_reserve.as_ref()]); - let (coll_price, _) = pda(&[b"price_feed", COLL_MINT.as_ref()]); - let (borrow_price, _) = pda(&[b"price_feed", BORROW_MINT.as_ref()]); + // Feed PDAs are seeded by their writing authority (the market owner here). + let (coll_price, _) = pda(&[b"price_feed", OWNER.as_ref(), COLL_MINT.as_ref()]); + let (borrow_price, _) = pda(&[b"price_feed", OWNER.as_ref(), BORROW_MINT.as_ref()]); let (obligation, _) = pda(&[b"obligation", market.as_ref(), BORROWER.as_ref()]); let (obligation_vault, _) = pda(&[b"obligation_vault", coll_reserve.as_ref(), obligation.as_ref()]); From 7dbaf8a45b72dd9d46de2c4ac904fefec0c38a9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 20:42:00 +0000 Subject: [PATCH 6/6] Add reserve factor so the market owner earns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Borrowers owe the full interest, but suppliers no longer receive all of it: each accrual now keeps `reserve_factor_bps` of the freshly accrued interest in the reserve's `accumulated_protocol_fees`, and the market owner withdraws it with a new `collect_protocol_fees` handler. The fees are carved out of `total_liquidity` (utilization still uses gross liquidity), so they never lift the supplier exchange rate, and collection is capped by the vault's available liquidity. This spread between the borrow and supply rates is the protocol's revenue — the missing profit motive for the market owner. Applied to both the Anchor and Quasar versions, with tests asserting fees accrue at the configured factor and that the owner collects them, plus README and CHANGELOG updates. https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9 --- finance/lending/anchor/CHANGELOG.md | 3 + finance/lending/anchor/README.md | 18 ++++- .../anchor/programs/lending/src/errors.rs | 2 + .../admin/collect_protocol_fees.rs | 81 +++++++++++++++++++ .../src/instructions/admin/init_reserve.rs | 1 + .../lending/src/instructions/admin/mod.rs | 2 + .../anchor/programs/lending/src/lib.rs | 4 + .../programs/lending/src/state/reserve.rs | 52 ++++++++++-- .../programs/lending/tests/common/mod.rs | 33 +++++++- .../programs/lending/tests/test_interest.rs | 45 +++++++++++ finance/lending/quasar/CHANGELOG.md | 2 + finance/lending/quasar/README.md | 8 +- finance/lending/quasar/src/error.rs | 1 + .../lending/quasar/src/instructions/admin.rs | 72 ++++++++++++++++- .../quasar/src/instructions/position.rs | 11 ++- .../lending/quasar/src/instructions/supply.rs | 8 +- finance/lending/quasar/src/lib.rs | 7 ++ finance/lending/quasar/src/logic.rs | 26 +++++- finance/lending/quasar/src/math.rs | 19 ++++- finance/lending/quasar/src/state.rs | 6 ++ finance/lending/quasar/src/tests.rs | 43 +++++++++- 21 files changed, 419 insertions(+), 25 deletions(-) create mode 100644 finance/lending/anchor/programs/lending/src/instructions/admin/collect_protocol_fees.rs diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 3c648ce8..2eaa9500 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -24,3 +24,6 @@ Initial lending program: a Kamino/Solend-style borrow/lend market. posted collateral (`LiquidationTooLarge`). - Withdraw health checks round the removed borrow power up, so independent rounding can never let a withdraw pass that an exact recompute would reject. +- Reserve factor: the protocol keeps `reserve_factor_bps` of accrued interest as + fees the market owner withdraws with `collect_protocol_fees`; the fees are + carved out of `total_liquidity` so they never inflate the supplier exchange rate. diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 76f4a074..c0a0e937 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -60,6 +60,16 @@ utilization. Each borrow stores its principal as **scaled debt** (principal ÷ index at borrow time), so every obligation's debt grows automatically as the index advances — no per-obligation accrual loop. +### Protocol fees (how the market earns) + +Borrowers owe the full interest, but suppliers don't receive all of it. On each +accrual the reserve keeps `config.reserve_factor_bps` of the freshly accrued +interest in `accumulated_protocol_fees`; only the remainder lifts the supplier +exchange rate. Those fees are carved out of `total_liquidity`, so they never +count as a supplier claim, and the market owner withdraws them with +**`collect_protocol_fees`** (paid out of the reserve's available liquidity). +This spread between the borrow rate and the supply rate is the protocol's revenue. + ### Obligation health `refresh_obligation` recomputes, from the refreshed reserves and their prices: @@ -110,8 +120,9 @@ rather than Pyth here for its lower compute cost. Supplied liquidity sits in program-owned vault PDAs, and posted collateral sits in per-obligation vault PDAs whose authority is the obligation PDA. The market owner -can update reserve risk parameters (`update_reserve_config`) but has no path to -move user funds — there is no admin withdrawal or escape hatch. +can update reserve risk parameters (`update_reserve_config`) and withdraw the +protocol's earned fees (`collect_protocol_fees`), but has no path to a supplier's +deposits or a borrower's collateral — there is no admin escape hatch over user funds. ### Known limits @@ -129,7 +140,8 @@ move user funds — there is no admin withdrawal or escape hatch. ### Instruction handlers -Admin: `init_lending_market`, `init_reserve`, `update_reserve_config`, `set_price`. +Admin: `init_lending_market`, `init_reserve`, `update_reserve_config`, `set_price`, +`collect_protocol_fees`. Supply side: `refresh_reserve`, `deposit_reserve_liquidity`, `redeem_reserve_collateral`. Borrow side: `init_obligation`, `refresh_obligation`, `deposit_obligation_collateral`, `withdraw_obligation_collateral`, diff --git a/finance/lending/anchor/programs/lending/src/errors.rs b/finance/lending/anchor/programs/lending/src/errors.rs index f07d965d..332906a2 100644 --- a/finance/lending/anchor/programs/lending/src/errors.rs +++ b/finance/lending/anchor/programs/lending/src/errors.rs @@ -36,4 +36,6 @@ pub enum LendingError { MarketMismatch, #[msg("Repay amount would seize more collateral than the obligation holds")] LiquidationTooLarge, + #[msg("No protocol fees are available to collect")] + NothingToCollect, } diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/collect_protocol_fees.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/collect_protocol_fees.rs new file mode 100644 index 00000000..0a5554db --- /dev/null +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/collect_protocol_fees.rs @@ -0,0 +1,81 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::LENDING_MARKET_SEED; +use crate::errors::LendingError; +use crate::state::{reserve_signer_seeds, LendingMarket, Reserve}; + +/// Withdraw the protocol fees accrued in a reserve to the market owner. This is +/// how the owner earns: `reserve_factor_bps` of every interest accrual is set +/// aside in `accumulated_protocol_fees` (never credited to suppliers), and this +/// handler pays it out, capped by the liquidity actually sitting in the vault. +pub fn handle_collect_protocol_fees(context: Context) -> Result<()> { + context.accounts.reserve.require_refreshed()?; + + let reserve = &mut context.accounts.reserve; + // Fees are a claim on liquidity; only what is currently un-borrowed can be paid + // out right now. Any remainder stays owed until borrowers repay. + let amount = reserve.accumulated_protocol_fees.min(reserve.available_liquidity); + require!(amount > 0, LendingError::NothingToCollect); + + reserve.accumulated_protocol_fees = reserve + .accumulated_protocol_fees + .checked_sub(amount) + .ok_or(LendingError::MathOverflow)?; + reserve.available_liquidity = reserve + .available_liquidity + .checked_sub(amount) + .ok_or(LendingError::MathOverflow)?; + + let bump = [reserve.bump]; + let seeds = reserve_signer_seeds(&reserve.lending_market, &reserve.liquidity_mint, &bump); + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.liquidity_vault.to_account_info(), + mint: context.accounts.liquidity_mint.to_account_info(), + to: context.accounts.owner_liquidity.to_account_info(), + authority: reserve.to_account_info(), + }, + &[&seeds], + ), + amount, + reserve.liquidity_decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct CollectProtocolFees<'info> { + #[account( + has_one = owner, + seeds = [LENDING_MARKET_SEED, owner.key().as_ref()], + bump = lending_market.bump, + )] + pub lending_market: Account<'info, LendingMarket>, + + #[account(mut)] + pub owner: Signer<'info>, + + #[account( + mut, + has_one = lending_market, + has_one = liquidity_mint, + has_one = liquidity_vault, + )] + pub reserve: Account<'info, Reserve>, + + pub liquidity_mint: InterfaceAccount<'info, Mint>, + + #[account(mut)] + pub liquidity_vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub owner_liquidity: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs index 5f75a6cc..ad9c57b6 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/init_reserve.rs @@ -21,6 +21,7 @@ pub fn handle_init_reserve(context: Context, config: ReserveConfig) reserve.borrowed_amount_scaled = 0; reserve.cumulative_borrow_rate_index = FIXED_POINT_SCALE; reserve.last_update_slot = Clock::get()?.slot; + reserve.accumulated_protocol_fees = 0; reserve.config = config; reserve.bump = context.bumps.reserve; Ok(()) diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/mod.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/mod.rs index 5737a8a1..ee52c93c 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/mod.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/mod.rs @@ -1,8 +1,10 @@ +pub mod collect_protocol_fees; pub mod init_lending_market; pub mod init_reserve; pub mod set_price; pub mod update_reserve_config; +pub use collect_protocol_fees::*; pub use init_lending_market::*; pub use init_reserve::*; pub use set_price::*; diff --git a/finance/lending/anchor/programs/lending/src/lib.rs b/finance/lending/anchor/programs/lending/src/lib.rs index e2892ca6..df2d3933 100644 --- a/finance/lending/anchor/programs/lending/src/lib.rs +++ b/finance/lending/anchor/programs/lending/src/lib.rs @@ -30,6 +30,10 @@ pub mod lending { instructions::handle_update_reserve_config(context, config) } + pub fn collect_protocol_fees(context: Context) -> Result<()> { + instructions::handle_collect_protocol_fees(context) + } + pub fn set_price( context: Context, price_mantissa: i128, diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index e5199977..9f13037f 100644 --- a/finance/lending/anchor/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -65,6 +65,12 @@ pub struct Reserve { pub last_update_slot: u64, + /// Liquidity owed to the market owner: the protocol's cut of accrued + /// interest (`config.reserve_factor_bps`). It is carved out of + /// `total_liquidity` so it never inflates the share exchange rate, and the + /// owner withdraws it with `collect_protocol_fees`. + pub accumulated_protocol_fees: u64, + pub config: ReserveConfig, pub bump: u8, @@ -81,6 +87,9 @@ pub struct ReserveConfig { pub liquidation_bonus_bps: u16, /// Maximum fraction of a borrow that one liquidation may repay. pub close_factor_bps: u16, + /// Share of accrued borrow interest kept by the protocol (the rest lifts the + /// supplier exchange rate). This is how the market owner earns. + pub reserve_factor_bps: u16, /// Utilization at which the borrow rate reaches `optimal_borrow_rate_bps`. pub optimal_utilization_bps: u16, /// Borrow APR at 0% utilization. @@ -99,6 +108,7 @@ impl ReserveConfig { && within_bps(self.liquidation_threshold_bps) && within_bps(self.liquidation_bonus_bps) && within_bps(self.close_factor_bps) + && within_bps(self.reserve_factor_bps) && within_bps(self.optimal_utilization_bps), LendingError::InvalidConfig ); @@ -135,20 +145,30 @@ impl Reserve { u64::try_from(amount).map_err(|_| LendingError::MathOverflow.into()) } - /// Available liquidity plus live debt — the pool size the share token is a claim on. - pub fn total_liquidity(&self) -> Result { - Ok((self.available_liquidity as u128) + /// Available liquidity plus live debt, before the protocol's fee is removed. + /// Used for the utilization ratio, which is about how much of the pool is lent + /// out, independent of who owns the interest. + pub fn gross_liquidity(&self) -> Result { + (self.available_liquidity as u128) .checked_add(self.current_borrowed_amount()? as u128) - .ok_or(LendingError::MathOverflow)?) + .ok_or(LendingError::MathOverflow.into()) + } + + /// The pool size the share token is a claim on: gross liquidity minus the + /// protocol fees owed to the owner, which belong to no supplier. + pub fn total_liquidity(&self) -> Result { + self.gross_liquidity()? + .checked_sub(self.accumulated_protocol_fees as u128) + .ok_or(LendingError::MathOverflow.into()) } /// Borrowed fraction of the pool, in basis points (0..=10_000). pub fn utilization_bps(&self) -> Result { - let total = self.total_liquidity()?; - if total == 0 { + let gross = self.gross_liquidity()?; + if gross == 0 { return Ok(0); } - mul_div_floor(self.current_borrowed_amount()? as u128, BPS_DENOMINATOR, total) + mul_div_floor(self.current_borrowed_amount()? as u128, BPS_DENOMINATOR, gross) } /// Per-slot borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve: @@ -198,6 +218,7 @@ impl Reserve { .ok_or(LendingError::MathOverflow)?; if elapsed > 0 && self.borrowed_amount_scaled > 0 { + let borrowed_before = self.current_borrowed_amount()?; let rate_per_slot = self.current_borrow_rate_per_slot()?; let accrued = rate_per_slot .checked_mul(elapsed as u128) @@ -210,6 +231,23 @@ impl Reserve { growth_factor, FIXED_POINT_SCALE, )?; + + // Borrowers owe the full interest (the index grew for all of it); the + // protocol keeps `reserve_factor_bps` of the newly accrued interest, + // and the remainder lifts the supplier exchange rate. Flooring the fee + // rounds the owner's cut down, in the suppliers' favour. + let interest = self + .current_borrowed_amount()? + .saturating_sub(borrowed_before); + let fee = mul_div_floor( + interest as u128, + self.config.reserve_factor_bps as u128, + BPS_DENOMINATOR, + )?; + self.accumulated_protocol_fees = self + .accumulated_protocol_fees + .checked_add(u64::try_from(fee).map_err(|_| LendingError::MathOverflow)?) + .ok_or(LendingError::MathOverflow)?; } self.last_update_slot = current_slot; diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index a4d73c65..c6beb8c5 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -656,6 +656,35 @@ impl Env { send(&mut self.svm, instructions, &[payer], &payer.pubkey()).unwrap(); } + /// Market owner collects accrued protocol fees from a reserve to their own + /// token account. Bundles `refresh_reserve` so fees are current. Returns the + /// owner's fee-receiving token account. + pub fn collect_protocol_fees(&mut self, handle: &ReserveHandle) -> Pubkey { + let owner = self.owner.insecure_clone(); + let owner_liquidity = ata(&owner.pubkey(), &handle.mint); + if self.svm.get_account(&owner_liquidity).is_none() { + create_associated_token_account(&mut self.svm, &owner.pubkey(), &handle.mint, &owner) + .unwrap(); + } + let refresh = self.refresh_reserve_ix(handle); + let collect = Instruction { + program_id: lending::id(), + accounts: lending::accounts::CollectProtocolFees { + lending_market: self.market, + owner: owner.pubkey(), + reserve: handle.reserve, + liquidity_mint: handle.mint, + liquidity_vault: handle.liquidity_vault, + owner_liquidity, + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None), + data: lending::instruction::CollectProtocolFees {}.data(), + }; + send(&mut self.svm, vec![refresh, collect], &[&owner], &owner.pubkey()).unwrap(); + owner_liquidity + } + // --- state readers --- pub fn reserve(&self, handle: &ReserveHandle) -> Reserve { @@ -674,13 +703,15 @@ impl Env { } /// A reasonable default reserve config: 75% LTV, 80% liquidation threshold, -/// 5% bonus, 50% close factor, kink at 80% utilization, 2%/20%/150% APR curve. +/// 5% bonus, 50% close factor, 10% reserve factor (protocol's cut of interest), +/// kink at 80% utilization, 2%/20%/150% APR curve. pub fn default_config() -> ReserveConfig { ReserveConfig { loan_to_value_bps: 7_500, liquidation_threshold_bps: 8_000, liquidation_bonus_bps: 500, close_factor_bps: 5_000, + reserve_factor_bps: 1_000, optimal_utilization_bps: 8_000, min_borrow_rate_bps: 200, optimal_borrow_rate_bps: 2_000, diff --git a/finance/lending/anchor/programs/lending/tests/test_interest.rs b/finance/lending/anchor/programs/lending/tests/test_interest.rs index 0fc8ed3c..a9c21c9b 100644 --- a/finance/lending/anchor/programs/lending/tests/test_interest.rs +++ b/finance/lending/anchor/programs/lending/tests/test_interest.rs @@ -61,3 +61,48 @@ fn interest_accrues_on_borrows_over_time() { "100M shares should redeem for more than 100M liquidity after interest, got {returned}" ); } + +/// The protocol keeps `reserve_factor_bps` of accrued interest as fees the +/// market owner can withdraw, while the rest lifts the supplier exchange rate. +#[test] +fn protocol_fees_accrue_and_owner_can_collect() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.init_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 500_000_000) + .unwrap(); + + // No interest has accrued yet, so no fees. + assert_eq!(env.reserve(&borrow).accumulated_protocol_fees, 0); + + env.warp_slots(7_884_000); + env.refresh_reserve_only(&borrower, &borrow); + + // Fees accrued, and they are ~10% (the reserve factor) of total interest. + let reserve = env.reserve(&borrow); + let fees = reserve.accumulated_protocol_fees; + assert!(fees > 0, "protocol fees should accrue once interest does"); + let total_interest = reserve.current_borrowed_amount().unwrap() - 500_000_000; + let expected_fee = total_interest / 10; // 1000 bps = 10% + // Allow a 1-unit rounding tolerance from flooring. + assert!( + fees.abs_diff(expected_fee) <= 1, + "fees {fees} should be ~10% of interest {total_interest}" + ); + + // Maria withdraws the fees to her own account. + let owner_account = env.collect_protocol_fees(&borrow); + assert_eq!(env.token_balance(owner_account), fees); + assert_eq!(env.reserve(&borrow).accumulated_protocol_fees, 0); +} diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index d52a8d03..795fac4f 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -19,3 +19,5 @@ Initial Quasar port of the Kamino/Solend-style borrow/lend program. - Liquidation reads the close factor from the borrow reserve, and rejects repayments whose seizure would exceed posted collateral (`LiquidationTooLarge`). +- Reserve factor: the protocol keeps `reserve_factor_bps` of accrued interest + as fees the market owner withdraws with `collect_protocol_fees`. diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 8c624583..db8812bc 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -52,6 +52,11 @@ Everything else mirrors the Anchor version. `total_liquidity / share_supply` rises as borrowers pay interest. `available_liquidity` (not the vault's raw balance) is the source of truth, so a token donation can't inflate the rate. +- **Protocol fees** — the reserve keeps `reserve_factor_bps` of each interest + accrual in `accumulated_protocol_fees` (carved out of total liquidity, so it + never lifts the supplier exchange rate); the market owner withdraws it with + `collect_protocol_fees`. That spread between the borrow and supply rates is how + the owner earns. - **Integer-only math** — `u128`, scaled by `FIXED_POINT_SCALE` (10^18), every conversion rounding in the protocol's favour. @@ -61,7 +66,8 @@ Everything else mirrors the Anchor version. `deposit_reserve_liquidity` (3), `redeem_reserve_collateral` (4), `init_obligation` (5), `deposit_obligation_collateral` (6), `withdraw_obligation_collateral` (7), `borrow_obligation_liquidity` (8), -`repay_obligation_liquidity` (9), `liquidate_obligation` (10). +`repay_obligation_liquidity` (9), `liquidate_obligation` (10), +`collect_protocol_fees` (11). ## Setup diff --git a/finance/lending/quasar/src/error.rs b/finance/lending/quasar/src/error.rs index 110afb3e..8d3b5e63 100644 --- a/finance/lending/quasar/src/error.rs +++ b/finance/lending/quasar/src/error.rs @@ -16,4 +16,5 @@ pub enum LendingError { ObligationHealthy, WrongReserve, LiquidationTooLarge, + NothingToCollect, } diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index 83e4b2cf..b996d3f5 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -1,7 +1,9 @@ use { crate::{ constants::{MINT_SPACE, TOKEN_ACCOUNT_SPACE}, - logic::now, + error::LendingError, + instructions::supply::reserve_seeds, + logic::{accrue, now, snapshot_reserve}, math::validate_config, state::{ LendingMarket, LendingMarketInner, LiquidityVaultPda, PriceFeed, PriceFeedInner, @@ -74,6 +76,7 @@ impl InitReserve { liquidation_threshold_bps: u16, liquidation_bonus_bps: u16, close_factor_bps: u16, + reserve_factor_bps: u16, optimal_utilization_bps: u16, min_borrow_rate_bps: u16, optimal_borrow_rate_bps: u16, @@ -85,6 +88,7 @@ impl InitReserve { liquidation_threshold_bps, liquidation_bonus_bps, close_factor_bps, + reserve_factor_bps, optimal_utilization_bps, min_borrow_rate_bps, optimal_borrow_rate_bps, @@ -153,6 +157,7 @@ impl InitReserve { price_feed: *self.price_feed.address(), available_liquidity: 0, share_mint_supply: 0, + accumulated_protocol_fees: 0, borrowed_amount_scaled: 0, cumulative_borrow_rate_index: crate::constants::FIXED_POINT_SCALE, last_update_slot: now()?, @@ -161,6 +166,7 @@ impl InitReserve { liquidation_threshold_bps, liquidation_bonus_bps, close_factor_bps, + reserve_factor_bps, optimal_utilization_bps, min_borrow_rate_bps, optimal_borrow_rate_bps, @@ -207,3 +213,67 @@ impl SetPrice { Ok(()) } } + +// --------------------------------------------------------------------------- +// collect_protocol_fees +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct CollectProtocolFees { + #[account(mut)] + pub owner: Signer, + #[account(has_one(owner), address = LendingMarket::seeds(owner.address()))] + pub lending_market: Account, + #[account(mut, has_one(lending_market), has_one(liquidity_mint), has_one(liquidity_vault))] + pub reserve: Account, + pub liquidity_mint: Account, + #[account(mut)] + pub liquidity_vault: Account, + #[account(mut)] + pub owner_liquidity: Account, + pub token_program: Program, +} + +impl CollectProtocolFees { + /// Pay the reserve's accrued protocol fees to the market owner. This is how + /// the owner earns: `reserve_factor_bps` of every interest accrual is set + /// aside in `accumulated_protocol_fees`, and this withdraws it — capped by + /// the liquidity currently sitting in the vault. + #[inline(always)] + pub fn run(&mut self) -> Result<(), ProgramError> { + let slot = now()?; + let mut reserve = snapshot_reserve(&self.reserve); + accrue(&mut reserve, slot)?; + + let amount = reserve + .accumulated_protocol_fees + .min(reserve.available_liquidity); + require!(amount > 0, LendingError::NothingToCollect); + reserve.accumulated_protocol_fees = reserve + .accumulated_protocol_fees + .checked_sub(amount) + .ok_or(LendingError::MathOverflow)?; + reserve.available_liquidity = reserve + .available_liquidity + .checked_sub(amount) + .ok_or(LendingError::MathOverflow)?; + + let decimals = reserve.liquidity_decimals; + let bump = [reserve.bump]; + let lending_market = reserve.lending_market; + let liquidity_mint = reserve.liquidity_mint; + self.reserve.set_inner(reserve); + + let seeds = reserve_seeds!(lending_market, liquidity_mint, bump); + self.token_program + .transfer_checked( + &self.liquidity_vault, + &self.liquidity_mint, + &self.owner_liquidity, + &self.reserve, + amount, + decimals, + ) + .invoke_signed(&seeds) + } +} diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs index 96e4fd06..5976b09f 100644 --- a/finance/lending/quasar/src/instructions/position.rs +++ b/finance/lending/quasar/src/instructions/position.rs @@ -4,7 +4,7 @@ use { error::LendingError, instructions::supply::reserve_seeds, logic::{accrue, now, price_scaled, snapshot_obligation, snapshot_reserve, SCALE}, - math::{current_debt, market_value, mul_div_ceil, mul_div_floor, total_liquidity, value_to_amount, Rounding}, + math::{current_debt, market_value, mul_div_ceil, mul_div_floor, net_total_liquidity, value_to_amount, Rounding}, state::{ LendingMarket, Obligation, ObligationInner, ObligationVaultPda, PriceFeed, Reserve, }, @@ -177,10 +177,11 @@ impl BorrowObligationLiquidity { } // Borrow power from collateral value. - let collateral_total = total_liquidity( + let collateral_total = net_total_liquidity( collateral.available_liquidity, collateral.borrowed_amount_scaled, collateral.cumulative_borrow_rate_index, + collateral.accumulated_protocol_fees, )?; let collateral_liquidity = mul_div_floor( obligation.deposited_shares as u128, @@ -363,10 +364,11 @@ impl WithdrawObligationCollateral { // Remaining collateral value after withdrawing `shares`. let remaining_shares = obligation.deposited_shares - shares; - let collateral_total = total_liquidity( + let collateral_total = net_total_liquidity( collateral.available_liquidity, collateral.borrowed_amount_scaled, collateral.cumulative_borrow_rate_index, + collateral.accumulated_protocol_fees, )?; let remaining_liquidity = mul_div_floor( remaining_shares as u128, @@ -476,10 +478,11 @@ impl LiquidateObligation { let borrow_price = price_scaled(&self.borrow_price, slot)?; // Health: unhealthy when debt value exceeds collateral value * liq threshold. - let collateral_total = total_liquidity( + let collateral_total = net_total_liquidity( collateral.available_liquidity, collateral.borrowed_amount_scaled, collateral.cumulative_borrow_rate_index, + collateral.accumulated_protocol_fees, )?; let collateral_liquidity = mul_div_floor( obligation.deposited_shares as u128, diff --git a/finance/lending/quasar/src/instructions/supply.rs b/finance/lending/quasar/src/instructions/supply.rs index ae0f5750..85678f03 100644 --- a/finance/lending/quasar/src/instructions/supply.rs +++ b/finance/lending/quasar/src/instructions/supply.rs @@ -2,7 +2,7 @@ use { crate::{ error::LendingError, logic::{accrue, now, snapshot_reserve}, - math::{mul_div_floor, total_liquidity}, + math::{mul_div_floor, net_total_liquidity}, state::Reserve, }, quasar_lang::prelude::*, @@ -53,10 +53,11 @@ impl DepositReserveLiquidity { let mut reserve = snapshot_reserve(&self.reserve); accrue(&mut reserve, slot)?; - let total = total_liquidity( + let total = net_total_liquidity( reserve.available_liquidity, reserve.borrowed_amount_scaled, reserve.cumulative_borrow_rate_index, + reserve.accumulated_protocol_fees, )?; let shares = if reserve.share_mint_supply == 0 { amount as u128 @@ -131,10 +132,11 @@ impl RedeemReserveCollateral { accrue(&mut reserve, slot)?; require!(reserve.share_mint_supply > 0, LendingError::InsufficientLiquidity); - let total = total_liquidity( + let total = net_total_liquidity( reserve.available_liquidity, reserve.borrowed_amount_scaled, reserve.cumulative_borrow_rate_index, + reserve.accumulated_protocol_fees, )?; let liquidity = mul_div_floor(shares as u128, total, reserve.share_mint_supply as u128)?; let liquidity = u64::try_from(liquidity).map_err(|_| LendingError::MathOverflow)?; diff --git a/finance/lending/quasar/src/lib.rs b/finance/lending/quasar/src/lib.rs index 56d85132..8358d7a8 100644 --- a/finance/lending/quasar/src/lib.rs +++ b/finance/lending/quasar/src/lib.rs @@ -46,6 +46,7 @@ mod quasar_lending { liquidation_threshold_bps: u16, liquidation_bonus_bps: u16, close_factor_bps: u16, + reserve_factor_bps: u16, optimal_utilization_bps: u16, min_borrow_rate_bps: u16, optimal_borrow_rate_bps: u16, @@ -56,6 +57,7 @@ mod quasar_lending { liquidation_threshold_bps, liquidation_bonus_bps, close_factor_bps, + reserve_factor_bps, optimal_utilization_bps, min_borrow_rate_bps, optimal_borrow_rate_bps, @@ -133,4 +135,9 @@ mod quasar_lending { ) -> Result<(), ProgramError> { ctx.accounts.run(amount) } + + #[instruction(discriminator = 11)] + pub fn collect_protocol_fees(ctx: Ctx) -> Result<(), ProgramError> { + ctx.accounts.run() + } } diff --git a/finance/lending/quasar/src/logic.rs b/finance/lending/quasar/src/logic.rs index c717b1f1..a2a53b7f 100644 --- a/finance/lending/quasar/src/logic.rs +++ b/finance/lending/quasar/src/logic.rs @@ -8,10 +8,12 @@ use quasar_lang::{prelude::*, sysvars::Sysvar}; use crate::{ constants::{FIXED_POINT_SCALE, MAX_PRICE_STALENESS_SLOTS}, error::LendingError, - math::{accrue_index, price_mantissa_to_scaled}, + math::{accrue_index, current_debt, mul_div_floor, price_mantissa_to_scaled}, state::{Obligation, ObligationInner, PriceFeed, Reserve, ReserveInner}, }; +use crate::constants::BPS_DENOMINATOR; + /// Current slot as a native `u64`. pub fn now() -> Result { Ok(u64::from(Clock::get()?.slot)) @@ -27,6 +29,7 @@ pub fn snapshot_reserve(reserve: &Account) -> ReserveInner { price_feed: reserve.price_feed, available_liquidity: u64::from(reserve.available_liquidity), share_mint_supply: u64::from(reserve.share_mint_supply), + accumulated_protocol_fees: u64::from(reserve.accumulated_protocol_fees), borrowed_amount_scaled: u128::from(reserve.borrowed_amount_scaled), cumulative_borrow_rate_index: u128::from(reserve.cumulative_borrow_rate_index), last_update_slot: u64::from(reserve.last_update_slot), @@ -35,6 +38,7 @@ pub fn snapshot_reserve(reserve: &Account) -> ReserveInner { liquidation_threshold_bps: u16::from(reserve.liquidation_threshold_bps), liquidation_bonus_bps: u16::from(reserve.liquidation_bonus_bps), close_factor_bps: u16::from(reserve.close_factor_bps), + reserve_factor_bps: u16::from(reserve.reserve_factor_bps), optimal_utilization_bps: u16::from(reserve.optimal_utilization_bps), min_borrow_rate_bps: u16::from(reserve.min_borrow_rate_bps), optimal_borrow_rate_bps: u16::from(reserve.optimal_borrow_rate_bps), @@ -59,6 +63,10 @@ pub fn snapshot_obligation(obligation: &Account) -> ObligationInner /// Advance a reserve snapshot's interest index to `slot` (Solend-style: a single /// `index *= 1 + rate_per_slot * elapsed` per call, compounding across calls). pub fn accrue(reserve: &mut ReserveInner, slot: u64) -> Result<(), ProgramError> { + let borrowed_before = current_debt( + reserve.borrowed_amount_scaled, + reserve.cumulative_borrow_rate_index, + )?; reserve.cumulative_borrow_rate_index = accrue_index( reserve.cumulative_borrow_rate_index, reserve.borrowed_amount_scaled, @@ -70,6 +78,22 @@ pub fn accrue(reserve: &mut ReserveInner, slot: u64) -> Result<(), ProgramError> reserve.optimal_borrow_rate_bps, reserve.max_borrow_rate_bps, )?; + // The protocol keeps `reserve_factor_bps` of the newly accrued interest; the + // rest lifts the supplier exchange rate. Flooring rounds the owner's cut down. + let borrowed_after = current_debt( + reserve.borrowed_amount_scaled, + reserve.cumulative_borrow_rate_index, + )?; + let interest = borrowed_after.saturating_sub(borrowed_before); + let fee = mul_div_floor( + interest as u128, + reserve.reserve_factor_bps as u128, + BPS_DENOMINATOR, + )?; + reserve.accumulated_protocol_fees = reserve + .accumulated_protocol_fees + .checked_add(u64::try_from(fee).map_err(|_| LendingError::MathOverflow)?) + .ok_or(LendingError::MathOverflow)?; reserve.last_update_slot = slot; Ok(()) } diff --git a/finance/lending/quasar/src/math.rs b/finance/lending/quasar/src/math.rs index b25b4ced..bd339211 100644 --- a/finance/lending/quasar/src/math.rs +++ b/finance/lending/quasar/src/math.rs @@ -91,7 +91,8 @@ pub fn current_debt(borrowed_scaled: u128, index: u128) -> Result Result { + total_liquidity(available, borrowed_scaled, index)? + .checked_sub(protocol_fees as u128) + .ok_or(LendingError::MathOverflow.into()) +} + /// Borrowed fraction of the pool in basis points (0..=10_000). pub fn utilization_bps( available: u64, @@ -186,11 +200,13 @@ pub fn accrue_index( mul_div_floor(index, growth, FIXED_POINT_SCALE) } +#[allow(clippy::too_many_arguments)] pub fn validate_config( loan_to_value_bps: u16, liquidation_threshold_bps: u16, liquidation_bonus_bps: u16, close_factor_bps: u16, + reserve_factor_bps: u16, optimal_utilization_bps: u16, min_borrow_rate_bps: u16, optimal_borrow_rate_bps: u16, @@ -202,6 +218,7 @@ pub fn validate_config( && within(liquidation_threshold_bps) && within(liquidation_bonus_bps) && within(close_factor_bps) + && within(reserve_factor_bps) && within(optimal_utilization_bps), LendingError::InvalidConfig ); diff --git a/finance/lending/quasar/src/state.rs b/finance/lending/quasar/src/state.rs index 04c3bcf9..e5283757 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -26,6 +26,10 @@ pub struct Reserve { pub price_feed: Address, pub available_liquidity: u64, pub share_mint_supply: u64, + /// Liquidity owed to the market owner: the protocol's cut of accrued + /// interest, carved out of total liquidity and withdrawn via + /// `collect_protocol_fees`. + pub accumulated_protocol_fees: u64, pub borrowed_amount_scaled: u128, pub cumulative_borrow_rate_index: u128, pub last_update_slot: u64, @@ -34,6 +38,8 @@ pub struct Reserve { pub liquidation_threshold_bps: u16, pub liquidation_bonus_bps: u16, pub close_factor_bps: u16, + /// Share of accrued borrow interest kept by the protocol (how the owner earns). + pub reserve_factor_bps: u16, pub optimal_utilization_bps: u16, pub min_borrow_rate_bps: u16, pub optimal_borrow_rate_bps: u16, diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index 3a522997..bdcd9919 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -36,6 +36,7 @@ const BORROWER_COLL_SHARE: Pubkey = Pubkey::new_from_array([13; 32]); const BORROWER_BORROW: Pubkey = Pubkey::new_from_array([14; 32]); const LIQUIDATOR_BORROW: Pubkey = Pubkey::new_from_array([15; 32]); const LIQUIDATOR_COLL_SHARE: Pubkey = Pubkey::new_from_array([16; 32]); +const OWNER_BORROW: Pubkey = Pubkey::new_from_array([17; 32]); fn token_program() -> Pubkey { quasar_svm::SPL_TOKEN_PROGRAM_ID @@ -165,6 +166,8 @@ impl World { token(BORROWER_BORROW, BORROW_MINT, BORROWER, 0), token(LIQUIDATOR_BORROW, BORROW_MINT, LIQUIDATOR, 1_000 * UNIT), token(LIQUIDATOR_COLL_SHARE, coll_share_mint, LIQUIDATOR, 0), + // Where the market owner receives collected protocol fees. + token(OWNER_BORROW, BORROW_MINT, OWNER, 0), ] { svm.set_account(account); } @@ -219,9 +222,9 @@ impl World { #[allow(clippy::too_many_arguments)] fn init_reserve(&mut self, the_mint: Pubkey, reserve: Pubkey, vault: Pubkey, share: Pubkey, price: Pubkey) { - // 75% LTV, 80% liq threshold, 5% bonus, 50% close factor, kink 80%, - // 2% / 20% / 150% APR curve. - let config: [u16; 8] = [7_500, 8_000, 500, 5_000, 8_000, 200, 2_000, 15_000]; + // 75% LTV, 80% liq threshold, 5% bonus, 50% close factor, 10% reserve + // factor, kink 80%, 2% / 20% / 150% APR curve. + let config: [u16; 9] = [7_500, 8_000, 500, 5_000, 1_000, 8_000, 200, 2_000, 15_000]; let mut data = vec![1u8]; for value in config { data.extend_from_slice(&value.to_le_bytes()); @@ -396,6 +399,21 @@ impl World { self.init_obligation(); self.post_collateral(1_000 * UNIT).assert_success(); } + + /// Market owner collects accrued protocol fees from the borrow reserve into + /// `OWNER_BORROW`. The handler accrues interest itself, so no separate refresh. + fn collect_borrow_fees(&mut self) -> quasar_svm::ExecutionResult { + let metas = vec![ + meta(OWNER, true, true), + meta(self.market, false, false), + meta(self.borrow_reserve, true, false), + meta(BORROW_MINT, false, false), + meta(self.borrow_vault, true, false), + meta(OWNER_BORROW, true, false), + meta(token_program(), false, false), + ]; + self.run(vec![11u8], metas) + } } #[test] @@ -486,3 +504,22 @@ fn unhealthy_position_is_liquidated_and_healthy_is_rejected() { "liquidator should receive seized collateral shares" ); } + +#[test] +fn protocol_fees_accrue_and_owner_can_collect() { + let mut world = World::new(); + world.bootstrap_position(); + world.borrow(500 * UNIT).assert_success(); + + // ~0.1 year passes; interest accrues, and the reserve factor (10%) sets some + // of it aside for the market owner. + world.svm.sysvars.warp_to_slot(7_884_000); + + let result = world.collect_borrow_fees(); + result.assert_success(); + assert!( + balance(&result, OWNER_BORROW) > 0, + "owner should collect a positive protocol fee, got {}", + balance(&result, OWNER_BORROW) + ); +}