|
| 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. |
0 commit comments