From 49398b56c8c581f82833439b642da36cbd69c3c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 21:33:11 +0000 Subject: [PATCH 1/3] 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/3] 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/3] 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" + ); +}