Skip to content

Commit 49398b5

Browse files
committed
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
1 parent 403f003 commit 49398b5

38 files changed

Lines changed: 3306 additions & 0 deletions

finance/lending/anchor/Anchor.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[toolchain]
2+
# Pinned to match the rest of solana-program-examples (see tokens/token-swap).
3+
# Unpin when the repo-wide Solana version is bumped.
4+
solana_version = "3.1.8"
5+
6+
[features]
7+
resolution = true
8+
skip-lint = false
9+
10+
[programs.localnet]
11+
lending = "4bvT6A8S7ZVL6bSvK2KoL2nQ4F5H6AF9133kCYbMJj1t"
12+
13+
[provider]
14+
cluster = "localnet"
15+
wallet = "~/.config/solana/id.json"
16+
17+
[scripts]
18+
# Anchor 1.0+ runs Rust + LiteSVM tests via cargo test.
19+
test = "cargo test"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Changelog
2+
3+
## 0.1.0
4+
5+
Initial lending program: a Kamino/Solend-style borrow/lend market.
6+
7+
- Lending market, per-asset reserves with a program-owned liquidity vault and a
8+
share-token mint, and per-borrower obligations.
9+
- Share-token deposit accounting with an exchange rate driven by accrued interest.
10+
- Utilization-based kinked interest-rate curve compounded through a cumulative
11+
borrow-rate index; per-obligation scaled debt.
12+
- Oracle-priced obligation health with loan-to-value and liquidation-threshold
13+
limits, and close-factor-capped liquidation with a seize bonus.
14+
- Switchboard-On-Demand-shaped price feed with a `set_price` test writer.
15+
- Rust + LiteSVM integration tests covering supply/redeem, borrow/repay,
16+
withdraw, interest accrual, liquidation, the share-inflation guard, and
17+
rounding/stale-input edge cases.

finance/lending/anchor/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[workspace]
2+
members = [
3+
"programs/*"
4+
]
5+
resolver = "2"
6+
7+
[profile.release]
8+
# overflow-checks is belt-and-braces: every arithmetic path in the program already
9+
# uses checked_* math, but enabling it means any missed raw op traps instead of wrapping.
10+
overflow-checks = true
11+
lto = "fat"
12+
codegen-units = 1
13+
14+
[profile.release.build-override]
15+
opt-level = 3
16+
incremental = false
17+
codegen-units = 1

finance/lending/anchor/README.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Lending
2+
3+
A Kamino/Solend-style borrow/lend program: suppliers earn interest on deposits,
4+
borrowers post collateral and draw other assets against it, and liquidators keep
5+
the market solvent. It demonstrates the techniques the most-used Solana lending
6+
protocols share — share-token deposit accounting, a utilization-based interest
7+
index, oracle-priced obligation health, and close-factor-capped liquidation.
8+
9+
## Purpose
10+
11+
Lending markets let one set of users supply liquidity to earn yield while another
12+
set borrows it against collateral. This program implements that end to end:
13+
14+
- **Suppliers** deposit a token and receive **share tokens** representing their
15+
slice of the pool. The share-to-liquidity exchange rate rises as borrowers pay
16+
interest, so redeeming later returns more than was deposited.
17+
- **Borrowers** post their share tokens as collateral in an obligation and borrow
18+
a different token, up to a loan-to-value limit.
19+
- **Liquidators** repay part of an unhealthy obligation's debt and seize its
20+
collateral at a discount, pulling the position back to solvency.
21+
22+
Concrete directional example (a short): supply USDC and post the USDC share
23+
tokens as collateral, borrow NVDAx, and sell it. You are **long your collateral
24+
(USDC) and short the borrowed asset (NVDAx)**. While the loan is open you pay a
25+
variable borrow rate that tracks pool utilization. Buy NVDAx back later, call
26+
`repay_obligation_liquidity`, then `withdraw_obligation_collateral` and
27+
`redeem_reserve_collateral` to exit. If NVDAx instead rises far enough, your debt
28+
crosses the liquidation threshold and a liquidator can close part of the position.
29+
30+
## Major Concepts
31+
32+
### Accounts
33+
34+
- **`LendingMarket`** — top-level config (owner, quote-currency mint). PDA seeds
35+
`["lending_market", owner]`.
36+
- **`Reserve`** — one per asset. Owns a program-controlled liquidity vault and a
37+
share-token mint, and stores the interest-rate config, the cumulative borrow-
38+
rate index, available liquidity, and scaled total debt. PDA seeds
39+
`["reserve", market, liquidity_mint]`.
40+
- **`Obligation`** — one per borrower per market: the share-token collateral
41+
posted and the liquidity borrowed, with cached quote-currency valuations. PDA
42+
seeds `["obligation", market, owner]`.
43+
- **`PriceFeed`** — a price for one token (see Oracle below).
44+
45+
### Share tokens (the deposit claim)
46+
47+
Supplying liquidity mints share tokens; redeeming burns them. The exchange rate
48+
is `total_liquidity / share_supply`, where `total_liquidity = available_liquidity
49+
+ current_debt`. `available_liquidity` (not the vault's raw token balance) is the
50+
source of truth, so a token donated directly to the vault cannot inflate the rate
51+
— closing the classic empty-pool inflation attack. The first deposit mints 1:1.
52+
53+
### Interest: a kinked curve and a cumulative index
54+
55+
Each `refresh_reserve` advances `cumulative_borrow_rate_index` by
56+
`(1 + rate_per_slot * elapsed_slots)`. `rate_per_slot` comes from a kinked
57+
utilization curve — linear from `min_borrow_rate_bps` to `optimal_borrow_rate_bps`
58+
up to `optimal_utilization_bps`, then steeper to `max_borrow_rate_bps` at full
59+
utilization. Each borrow stores its principal as **scaled debt** (principal ÷
60+
index at borrow time), so every obligation's debt grows automatically as the
61+
index advances — no per-obligation accrual loop.
62+
63+
### Obligation health
64+
65+
`refresh_obligation` recomputes, from the refreshed reserves and their prices:
66+
`borrowed_value`, `allowed_borrow_value` (Σ collateral value × `loan_to_value_bps`)
67+
and `unhealthy_borrow_value` (Σ collateral value × `liquidation_threshold_bps`).
68+
Borrowing and withdrawing are gated by `allowed_borrow_value`; an obligation is
69+
liquidatable once `borrowed_value > unhealthy_borrow_value`. Collateral is valued
70+
rounding down and debt rounding up, so health is always judged conservatively.
71+
72+
### Fixed-point math
73+
74+
All money math is integer-only `u128` — no floats, no fixed-point crates. Ratios
75+
(rates, the index, the exchange rate, obligation values) are scaled by
76+
`FIXED_POINT_SCALE` (10^18). Every conversion rounds in the protocol's favour
77+
(user output floored, debt ceiled), so dust cannot be extracted by repeated
78+
round-trips.
79+
80+
### Oracle
81+
82+
`PriceFeed` mirrors a Switchboard On-Demand pull feed: a signed mantissa, an
83+
exponent (`price = mantissa * 10^exponent`), and the slot the price was written.
84+
Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not wall-clock
85+
time. The `set_price` handler writes the feed directly so the LiteSVM tests are
86+
deterministic; in production a reserve points at the real Switchboard feed and the
87+
program decodes `PullFeedAccountData` (`price_mantissa = current_result.value`,
88+
`exponent = -18`, `last_updated_slot = current_result.slot`) instead. Switchboard
89+
is used rather than Pyth here for its lower compute cost.
90+
91+
### Custody
92+
93+
Supplied liquidity sits in program-owned vault PDAs, and posted collateral sits in
94+
per-obligation vault PDAs whose authority is the obligation PDA. The market owner
95+
can update reserve risk parameters (`update_reserve_config`) but has no path to
96+
move user funds — there is no admin withdrawal or escape hatch.
97+
98+
### Instruction handlers
99+
100+
Admin: `init_lending_market`, `init_reserve`, `update_reserve_config`, `set_price`.
101+
Supply side: `refresh_reserve`, `deposit_reserve_liquidity`,
102+
`redeem_reserve_collateral`. Borrow side: `init_obligation`, `refresh_obligation`,
103+
`deposit_obligation_collateral`, `withdraw_obligation_collateral`,
104+
`borrow_obligation_liquidity`, `repay_obligation_liquidity`, `liquidate_obligation`.
105+
106+
Value-dependent handlers require the reserves and the obligation to have been
107+
refreshed in the same transaction, so a typical action transaction is
108+
`[refresh_reserve …, refresh_obligation, <action>]`.
109+
110+
## Setup
111+
112+
- Rust and the Solana toolchain (`cargo-build-sbf`), Anchor 1.0.x, Solana 3.1.8.
113+
- This program has no client/JavaScript code; tests are Rust + LiteSVM.
114+
115+
## Testing
116+
117+
```sh
118+
anchor build # or: cargo build-sbf — produces target/deploy/lending.so
119+
anchor test # or: cargo test — runs the LiteSVM integration tests
120+
```
121+
122+
`anchor build` (or `cargo build-sbf`) must run first: the tests load the compiled
123+
`target/deploy/lending.so` via `include_bytes!`. The suite covers the
124+
non-happy-path branches — interest accrual, borrowing at the LTV limit, stale
125+
reserve/price rejection, liquidation of an unhealthy obligation after a price
126+
move, the share-inflation guard, and rounding edges.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "lending"
3+
version = "0.1.0"
4+
description = "Kamino/Solend-style borrow/lend program"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "lending"
10+
11+
[features]
12+
default = []
13+
cpi = ["no-entrypoint"]
14+
no-entrypoint = []
15+
no-idl = []
16+
no-log-ix-name = []
17+
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
18+
anchor-debug = []
19+
custom-heap = []
20+
custom-panic = []
21+
22+
[dependencies]
23+
# init-if-needed: the obligation share vault and the test price feed are created lazily.
24+
anchor-lang = { version = "1.0.0", features = ["init-if-needed"] }
25+
anchor-spl = "1.0.0"
26+
27+
[dev-dependencies]
28+
litesvm = "0.11.0"
29+
solana-signer = "3.0.0"
30+
solana-keypair = "3.0.1"
31+
solana-kite = "0.3.0"
32+
borsh = "1.6.1"
33+
34+
[lints.rust]
35+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[target.bpfel-unknown-unknown.dependencies.std]
2+
features = []
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use anchor_lang::prelude::*;
2+
3+
/// Fixed-point scale for every ratio in the program: interest rates, the
4+
/// cumulative borrow-rate index, the share-token exchange rate, and obligation
5+
/// values. A ratio `r` is stored as the integer `r * FIXED_POINT_SCALE`.
6+
///
7+
/// All money math is integer-only (no floats, no fixed-point crates). 10^18
8+
/// keeps a single slot's interest — which can be a tiny fraction of the index —
9+
/// from truncating to zero, while u128's ~3.4e38 ceiling leaves headroom for the
10+
/// index to grow and for intermediate products before the final narrowing cast.
11+
#[constant]
12+
pub const FIXED_POINT_SCALE: u128 = 1_000_000_000_000_000_000;
13+
14+
/// log10(FIXED_POINT_SCALE). Used to fold the price exponent and the fixed-point
15+
/// scale into one power of ten so price conversions never form a needless 10^18
16+
/// intermediate that would overflow for high-priced assets.
17+
pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18;
18+
19+
/// Denominator for every basis-point config value. 100% == 10_000 bps.
20+
#[constant]
21+
pub const BPS_DENOMINATOR: u128 = 10_000;
22+
23+
/// Slots per year, for turning an APR (in bps) into a per-slot rate.
24+
/// Solana targets ~2.5 slots/second: 2.5 * 60 * 60 * 24 * 365 = 78_840_000.
25+
#[constant]
26+
pub const SLOTS_PER_YEAR: u128 = 78_840_000;
27+
28+
/// Maximum distinct reserves an obligation may use as collateral, and
29+
/// separately as borrows. Bounds the account size and the compute cost of
30+
/// refresh_obligation (which iterates every entry).
31+
pub const MAX_OBLIGATION_RESERVES: usize = 4;
32+
33+
/// A price feed older than this many slots is rejected as stale (~10s at 2.5
34+
/// slots/second). Freshness is measured in slots, not unix time, because the
35+
/// runtime guarantees slot progression while the timestamp is validator-influenced.
36+
#[constant]
37+
pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25;
38+
39+
// PDA seeds.
40+
pub const LENDING_MARKET_SEED: &[u8] = b"lending_market";
41+
pub const RESERVE_SEED: &[u8] = b"reserve";
42+
pub const LIQUIDITY_VAULT_SEED: &[u8] = b"liquidity_vault";
43+
pub const SHARE_MINT_SEED: &[u8] = b"share_mint";
44+
pub const OBLIGATION_SEED: &[u8] = b"obligation";
45+
pub const OBLIGATION_SHARE_VAULT_SEED: &[u8] = b"obligation_share_vault";
46+
pub const PRICE_FEED_SEED: &[u8] = b"price_feed";
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
use anchor_lang::prelude::*;
2+
3+
#[error_code]
4+
pub enum LendingError {
5+
#[msg("Arithmetic operation overflowed")]
6+
MathOverflow,
7+
#[msg("Reserve config has an invalid value")]
8+
InvalidConfig,
9+
#[msg("Amount must be greater than zero")]
10+
ZeroAmount,
11+
#[msg("Deposit is too small to mint any share tokens")]
12+
DepositTooSmall,
13+
#[msg("Reserve does not have enough available liquidity")]
14+
InsufficientReserveLiquidity,
15+
#[msg("Reserve must be refreshed in this same transaction before use")]
16+
ReserveStale,
17+
#[msg("Obligation must be refreshed in this same transaction before use")]
18+
ObligationStale,
19+
#[msg("Price feed has not been updated recently enough")]
20+
StalePriceFeed,
21+
#[msg("Price feed reported a non-positive price")]
22+
InvalidOraclePrice,
23+
#[msg("Borrow would exceed the obligation's allowed borrow value")]
24+
BorrowTooLarge,
25+
#[msg("Withdraw would leave the obligation undercollateralized")]
26+
WithdrawTooLarge,
27+
#[msg("Obligation is healthy and cannot be liquidated")]
28+
ObligationHealthy,
29+
#[msg("Obligation already uses the maximum number of reserves")]
30+
TooManyReserves,
31+
#[msg("Reserve is not part of this obligation")]
32+
ReserveNotFound,
33+
#[msg("A refresh account did not match the obligation's stored reserves")]
34+
InvalidObligationAccount,
35+
#[msg("Signer is not authorized for this price feed")]
36+
UnauthorizedPriceFeed,
37+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
use anchor_lang::prelude::*;
2+
use anchor_spl::token_interface::Mint;
3+
4+
use crate::constants::LENDING_MARKET_SEED;
5+
use crate::state::LendingMarket;
6+
7+
pub fn handle_init_lending_market(context: Context<InitLendingMarket>) -> Result<()> {
8+
let market = &mut context.accounts.lending_market;
9+
market.owner = context.accounts.owner.key();
10+
market.quote_currency_mint = context.accounts.quote_currency_mint.key();
11+
market.bump = context.bumps.lending_market;
12+
Ok(())
13+
}
14+
15+
#[derive(Accounts)]
16+
pub struct InitLendingMarket<'info> {
17+
#[account(
18+
init,
19+
payer = owner,
20+
space = LendingMarket::DISCRIMINATOR.len() + LendingMarket::INIT_SPACE,
21+
seeds = [LENDING_MARKET_SEED, owner.key().as_ref()],
22+
bump,
23+
)]
24+
pub lending_market: Account<'info, LendingMarket>,
25+
26+
#[account(mut)]
27+
pub owner: Signer<'info>,
28+
29+
pub quote_currency_mint: InterfaceAccount<'info, Mint>,
30+
31+
pub system_program: Program<'info, System>,
32+
}

0 commit comments

Comments
 (0)