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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions finance/lending/anchor/Anchor.toml
Original file line number Diff line number Diff line change
@@ -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"
17 changes: 17 additions & 0 deletions finance/lending/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions finance/lending/anchor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
126 changes: 126 additions & 0 deletions finance/lending/anchor/README.md
Original file line number Diff line number Diff line change
@@ -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, <action>]`.

## 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.
35 changes: 35 additions & 0 deletions finance/lending/anchor/programs/lending/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"))'] }
2 changes: 2 additions & 0 deletions finance/lending/anchor/programs/lending/Xargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[target.bpfel-unknown-unknown.dependencies.std]
features = []
45 changes: 45 additions & 0 deletions finance/lending/anchor/programs/lending/src/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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
/// 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.
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.
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.
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.
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";
37 changes: 37 additions & 0 deletions finance/lending/anchor/programs/lending/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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<InitLendingMarket>) -> 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>,
}
Loading
Loading