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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
29 changes: 29 additions & 0 deletions finance/lending/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 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.
- Lending markets are isolation boundaries: every obligation handler rejects
reserves from another market (`MarketMismatch`).
- Price feed PDAs are seeded by their authority, so no signer can write or
pre-claim a feed another authority's reserves trust.
- Liquidation reads the close factor from the repay reserve, the bonus from the
collateral reserve, and rejects repayments whose seizure would exceed the
posted collateral (`LiquidationTooLarge`).
- Withdraw health checks round the removed borrow power up, so independent
rounding can never let a withdraw pass that an exact recompute would reject.
- Reserve factor: the protocol keeps `reserve_factor_bps` of accrued interest as
fees the market owner withdraws with `collect_protocol_fees`; the fees are
carved out of `total_liquidity` so they never inflate the supplier exchange rate.
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
170 changes: 170 additions & 0 deletions finance/lending/anchor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# 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.

### Protocol fees (how the market earns)

Borrowers owe the full interest, but suppliers don't receive all of it. On each
accrual the reserve keeps `config.reserve_factor_bps` of the freshly accrued
interest in `accumulated_protocol_fees`; only the remainder lifts the supplier
exchange rate. Those fees are carved out of `total_liquidity`, so they never
count as a supplier claim, and the market owner withdraws them with
**`collect_protocol_fees`** (paid out of the reserve's available liquidity).
This spread between the borrow rate and the supply rate is the protocol's revenue.

### Obligation health

`refresh_obligation` recomputes, from the refreshed reserves and their prices:
`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.

Every handler that pairs an obligation with a reserve requires both to belong to
the same `LendingMarket` (`MarketMismatch` otherwise), so each market is an
isolation boundary: positions in one market can never be valued or settled
against reserves of another.

In a liquidation, the close factor (how much of the borrow one call may repay)
comes from the **repay reserve**, because it is a property of the debt being
closed; the liquidation bonus comes from the **collateral reserve**, because it
prices the collateral being seized. A repayment whose seizure would exceed the
posted collateral fails with `LiquidationTooLarge` rather than silently seizing
less, which would make the liquidator overpay.

### Fixed-point math

All money math is integer-only `u128` — no floats, no fixed-point crates. Ratios
(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 feed PDA is seeded by `[b"price_feed", authority, mint]`, so a signer
can only ever write the feed derived from their own key — there is no shared
per-mint feed to claim first — and a reserve trusts exactly one feed: the
account its market owner passed to `init_reserve`.

The `set_price` handler writes the feed directly so the LiteSVM tests are
deterministic; in production a reserve points at the real Switchboard feed and the
program decodes `PullFeedAccountData` (`price_mantissa = current_result.value`,
`exponent = -18`, `last_updated_slot = current_result.slot`) instead, and should
also reject results whose confidence interval is too wide. 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`) and withdraw the
protocol's earned fees (`collect_protocol_fees`), but has no path to a supplier's
deposits or a borrower's collateral — there is no admin escape hatch over user funds.

### Known limits

- **Tokens with transfer fees are not supported.** The program uses
`token_interface`, so Token Extensions mints are accepted, but a transfer-fee
extension would make the vault receive less than the recorded deposit and the
accounting would overstate `available_liquidity`. Production protocols
whitelist mints; a market owner here must only create reserves for tokens
without transfer fees.
- **Reserve config changes act immediately.** Lowering a reserve's
`liquidation_threshold_bps` can make existing obligations liquidatable at
once; production governance phases such changes in.
- This is an example. Deploying any program that custodies funds calls for a
professional security audit first.

### Instruction handlers

Admin: `init_lending_market`, `init_reserve`, `update_reserve_config`, `set_price`,
`collect_protocol_fees`.
Supply side: `refresh_reserve`, `deposit_reserve_liquidity`,
`redeem_reserve_collateral`. Borrow side: `init_obligation`, `refresh_obligation`,
`deposit_obligation_collateral`, `withdraw_obligation_collateral`,
`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";
41 changes: 41 additions & 0 deletions finance/lending/anchor/programs/lending/src/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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("Reserve belongs to a different lending market than the obligation")]
MarketMismatch,
#[msg("Repay amount would seize more collateral than the obligation holds")]
LiquidationTooLarge,
#[msg("No protocol fees are available to collect")]
NothingToCollect,
}
Loading
Loading