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
2 changes: 1 addition & 1 deletion WRITING-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ What is specific to the book: the conventions are fixed during the single visual

Steps currently missing their per-step diagram, to be drawn in the visual pass:

- Lending and Borrowing: Carol's short; Bob's repayment and collateral withdrawal.
- Lending and Borrowing: Maria's opening deposit into each reserve, and her three share accounts in the account map; Carol's short; Bob's repayment and collateral withdrawal.
- Perpetual Futures: Alice's `close_position` payout.
- The Prop AMM: Maria's `deposit_inventory` and `withdraw_inventory`.
- The Vault Strategy: Bob's deposit at the new share price.
Expand Down
12 changes: 6 additions & 6 deletions building-financial-products-in-solana/lending.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Chapter 17: Lending and Borrowing

Start with a commercial bank. Savers deposit money and earn interest. Borrowers take loans and pay interest. The bank sits in the middle, collecting from the borrowers, paying most of it to the savers, and keeping a slice for itself. The onchain equivalent is a borrow-lend market, usually just called a **lending market**: Save, Kamino Lend, and MarginFi are popular lending markets on Solana, and lending is one of the largest categories of capital on the network.[^lending-size] The program for this chapter lives at `finance/lending`, and it is the largest program in the book so far: interest that compounds by the slot, collateral that gets marked to market, and a liquidation engine that keeps the whole thing solvent when prices move.
Start with a commercial bank. Savers deposit money and earn interest. Borrowers take loans and pay interest. The bank sits in the middle, collecting from the borrowers, paying most of it to the savers, and keeping a slice for itself. The onchain equivalent is a borrow-lend market, usually just called a **lending market**: Save, Kamino Lend, and MarginFi are popular lending markets on Solana, and lending is one of the largest categories of capital on the network.[^lending-size] The program for this chapter lives at `finance/lending`, and it is the largest program in the book so far: interest that compounds on every refresh, collateral that gets marked to market, and a liquidation engine that keeps the whole thing solvent when prices move.

One word is overloaded. This chapter's market trades stocks like NVDAx and SPCXx, which people colloquially call "shares." **In this chapter, a share is a share of a reserve**: a token minted by the program representing your claim on a pool of deposited assets, including the interest it earns. The stocks are just tokens.

Expand All @@ -12,7 +12,7 @@ Every valuation in this chapter multiplies an amount by a price, and the prices

## The Market Owner Opens the Market

Maria runs the market, and her revenue is a cut of the interest, the reserve factor set on every reserve below. She runs `initialize_lending_market()` with USDC as the quote currency, then `initialize_reserve()` three times, for USDC, NVDAx, and SPCXx, each with its config and price feed. Each reserve's `borrow_accumulation_factor` starts at exactly 1.0, represented as 10^18 in the program's fixed-point scale, chosen so a single slot's interest does not truncate to zero while staying far inside u128's ceiling. All the math in this program is integer-only: no floats, no fixed-point crates, just u128, a scale constant, and two rounding helpers.
Maria runs the market, and her revenue is a cut of the interest, the reserve factor set on every reserve below. She runs `initialize_lending_market()` with USDC as the quote currency, then `initialize_reserve()` three times, for USDC, NVDAx, and SPCXx, each with its config and price feed. Each reserve's `borrow_accumulation_factor` starts at exactly 1.0, represented as 10^18 in the program's fixed-point scale, chosen so a single second's interest does not truncate to zero while staying far inside u128's ceiling. All the math in this program is integer-only: no floats, no fixed-point crates, just u128, a scale constant, and two rounding helpers.

![](figures/lending-open-market.svg)

Expand All @@ -35,7 +35,7 @@ Each reserve carries a `ReserveConfig`, all in basis points, and its validation

## Supplying: Tokens In, Shares Out

Alice has her standard 1,000 USDC, plus four SPCXx (SpaceX stock, $250 each), sitting idle, and she wants yield. She runs `deposit_reserve_liquidity()` with her 1,000 USDC. The first deposit into a reserve mints shares one-to-one, so she receives 1,000 USDC shares; she does the same with her four SPCXx. Later deposits mint by the going rate: shares = amount × share supply ÷ total liquidity, floored, where total liquidity is available plus borrowed (with accrued interest) minus the house's accumulated fees. The share price is how yield arrives: interest grows the pool while the share supply is unchanged, so each share redeems for more than it did. The program keeps no per-lender interest records.
Alice has her standard 1,000 USDC, plus four SPCXx (SpaceX stock, $250 each), sitting idle, and she wants yield. She runs `deposit_reserve_liquidity()` with her 1,000 USDC. Maria has already made the first deposit into each reserve, 1,001 minor units. A reserve's first deposit mints one share per minor unit, less 1,000 shares that are withheld and never minted, the program's `MINIMUM_SHARES`. So Maria holds one share, and the 1,000 withheld shares belong to nobody. Every later deposit mints by the going rate: shares = amount × (share supply + 1,000) ÷ total liquidity, floored, where total liquidity is available plus borrowed (with accrued interest) minus the house's accumulated fees. The withheld 1,000 count in every conversion between shares and liquidity, and the failure-modes section says why. The USDC reserve holds 1,001 minor units against 1,001 shares, so Alice receives 1,000 USDC shares; she does the same with her four SPCXx. The share price is how yield arrives: interest grows the pool while the share supply is unchanged, so each share redeems for more than it did. The program keeps no per-lender interest records.

![Interest grows the pool while the share supply is unchanged, so the same shares redeem for more USDC later.](figures/lending-deposit-shares.svg)

Expand Down Expand Up @@ -129,7 +129,7 @@ Every number in this program flows through `mul_div_floor()` or `mul_div_ceil()`

## The Invariants: Solvency by Construction

- Total liquidity = available + borrowed-with-interest − the house's accumulated fees, and the share supply is a claim on exactly that; the exchange rate cannot be moved by donating tokens to the vault (checked by `raw_token_donation_does_not_inflate_exchange_rate`).
- Total liquidity = available + borrowed-with-interest − the house's accumulated fees, and the share supply plus the 1,000 withheld shares is a claim on exactly that; the exchange rate cannot be moved by donating tokens to the vault (checked by `raw_token_donation_does_not_inflate_exchange_rate`).
- No action may leave an obligation past its allowed borrow value, and every valuation is fresh (same transaction) or refused.
- Loan-to-value ≤ liquidation threshold in every valid config, so there is always a buffer between the most you can borrow and the point you can be liquidated.
- The factor never decreases, and debt computed from it never rounds in the borrower's favor.
Expand All @@ -141,12 +141,12 @@ Every number in this program flows through `mul_div_floor()` or `mul_div_ceil()`
- **Skipping the refresh** would let positions be judged on old values; `ReserveStale`, `ObligationStale`, and `borrow_without_obligation_refresh_is_rejected` close the path.
- **Cross-market smuggling**: an obligation in one market pledging reserves from another is refused (`MarketMismatch`, `cross_market_reserve_is_rejected`).
- **Dangerous configurations are rejected at write time**: LTV above threshold, zero close factor, misordered rate curves (`rejects_ltv_above_liquidation_threshold`, `rejects_misordered_interest_rate_curve`).
- **The donation attack** fails because `available_liquidity`, not the vault balance, is the source of truth.
- **Share inflation through the reserve's own borrowing.** Donating tokens to the vault moves nothing, because `available_liquidity`, not the vault balance, is the source of truth. But total liquidity also counts interest owed on borrows, and a supplier may borrow from the reserve they supply. The attacker is the only supplier of an empty reserve, holding one share, and borrows one minor unit from it against collateral in another reserve. Debt rounds up, so a second of interest makes that debt two minor units, and the one share is worth two without anything being minted. From there, deposits and redemptions push the share price higher, because both round in the pool's favor and the attacker owns the whole pool. Once one share is worth about 690 USDC, a 1,000 USDC deposit mints one share, and the attacker's share redeems half the pool. `inflating_shares_through_own_borrow_does_not_pay` runs this on the default rate curve, and the attacker repays the loan and leaves no bad debt. Without the withheld shares the attacker turns 4,000 USDC into 4,155.20. With them, the attacker's share is one of 1,001, and nearly everything the rounding leaves behind goes to shares nobody can redeem. The attacker ends 1,065 minor units down, and the victim gets back the whole deposit. A reserve whose suppliers have all left keeps the 1,000 minor units behind the withheld shares, and the next deposit is priced against them instead of starting the reserve over (`sole_supplier_leaves_the_minimum_behind`).
- **Self-liquidation** is allowed, deliberately: it is only possible while unhealthy and is economically pointless, matching how Save and Kamino Lend behave. Not every strange-looking path needs a check; this one is harmless because it never pays.

## Testing Considerations

The suite is organized by risk, one file per class. Rounding comes first: the round-trip-creates-no-value invariant, and the withdraw-at-the-boundary-then-one-more-unit test, which is the "test both sides of every boundary" rule. Then deposits and redemptions, interest over time (using slot-warping to make weeks pass), liquidation (healthy rejected, unhealthy corrected with bonus and close factor, over-seizure refused), borrowing (every rejection path), and security (cross-market and unauthorized-price attempts). All of it drives the program's own handlers, with each action bundling its refresh instructions into the same transaction as a production client would.
The suite is organized by risk, one file per class. Rounding comes first: the round-trip-creates-no-value invariant, and the withdraw-at-the-boundary-then-one-more-unit test, which is the "test both sides of every boundary" rule. Then deposits and redemptions, including the share-inflation attack and a first deposit too small to clear the withheld shares. Then interest over time (moving the clock forward to make weeks pass), liquidation (healthy rejected, unhealthy corrected with bonus and close factor, over-seizure refused), borrowing (every rejection path), and security (cross-market and unauthorized-price attempts). All of it drives the program's own handlers, with each action bundling its refresh instructions into the same transaction as a production client would.

## What You Learnt from Building the Lending Market

Expand Down
4 changes: 2 additions & 2 deletions financial-programming-concepts-in-solana/the-adversary.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ Bob can protect his own deposit with a minimum-output bound. The vault strategy'

- **Lock some shares that no one owns.** On the first deposit, the program adds a fixed number of shares to the supply without minting them to anyone, so the supply never starts at dust. Say 100 shares are locked, the size of the AMM's `MINIMUM_LIQUIDITY` floor, and every formula counts them in the supply it divides by. The attacker's smallest position is then one share out of 101. Pricing each share at 1,000 USDC takes a donation of about 101,000 USDC, and 100 of every 101 units of it belong to shares nobody can withdraw. The attacker spends 101,000 USDC to zero out Bob's 1,000, then can withdraw only about 1,010 USDC.
- **Add virtual shares and virtual value to the formula.** The program adds a fixed number of shares to the supply and a fixed amount to the pool's value, but only inside the division, so it works normally even when no real shares exist. Say a million virtual shares and one virtual minor unit. The attacker's one-minor-unit deposit now buys a million shares, and the donation is split between their million and the virtual million. Bob's 1,000 USDC buys 1,999,999 shares, which redeem for 999.99975 USDC. The attacker's shares redeem for about 500 USDC, and the other half of the donation belongs to the virtual shares, which nobody can withdraw.
- **Ignore donations.** The program records the pool's size in its own state, as the lending market's `available_liquidity` and the vault strategy's recorded holdings do, rather than reading the vault's token balance. Step 2 then changes the vault's balance and nothing the handler reads. The pool is still one minor unit against one share, and Bob's deposit mints a share per minor unit as it should. The donated 1,000 USDC sits in the vault outside the program's accounting, lost to the attacker, and the lending example tests this with `raw_token_donation_does_not_inflate_exchange_rate`. The vault strategy's `test_donation_does_not_inflate_share_price` runs these exact numbers. A recorded size stops donations only while the attacker has no other way to raise it, and the *Perpetual Futures* chapter shows one: a provider who is also the pool's only trader can pay funding into it.
- **Ignore donations.** The program records the pool's size in its own state, as the lending market's `available_liquidity` and the vault strategy's recorded holdings do, rather than reading the vault's token balance. Step 2 then changes the vault's balance and nothing the handler reads. The pool is still one minor unit against one share, and Bob's deposit mints a share per minor unit as it should. The donated 1,000 USDC sits in the vault outside the program's accounting, lost to the attacker, and the lending example tests this with `raw_token_donation_does_not_inflate_exchange_rate`. The vault strategy's `test_donation_does_not_inflate_share_price` runs these exact numbers. A recorded size stops donations only while the attacker has no other way to raise it. The *Perpetual Futures* chapter shows one: a provider who is also the pool's only trader can pay funding into it. The *Lending and Borrowing* chapter shows another: a lending reserve's size counts the interest its borrowers owe, and a supplier may borrow from their own reserve. One minor unit of interest is enough to start. After that, the fraction each of the attacker's own deposits loses to the floor stays in the pool and raises their share further.
- **Require a large first deposit.** If the first deposit must be at least 100 USDC, the attacker's smallest position is 100,000,000 shares. Rounding Bob's 1,000 USDC down to nothing would then take a share price above 1,000 USDC, a vault holding a hundred billion dollars.

A program needs at least one of these four, chosen on purpose, and has to check that nothing else it allows raises the pool's value the way a donation does. The book's programs each name theirs. The AMM locks shares, and the lending market and the vault strategy ignore donations. The perpetual futures pool does both: it ignores donations by pricing shares from its recorded `liquidity`, and locks shares because its own traders can raise that figure.
A program needs at least one of these four, chosen on purpose, and has to check that nothing else it allows raises the pool's value the way a donation does. The book's programs each name theirs. The AMM locks shares, and the vault strategy ignores donations. The lending market and the perpetual futures pool do both. Each ignores donations by pricing shares from figures it records rather than its vault's balance. Each also locks shares, because its own users can raise those figures: borrowers with the interest they owe, traders with the funding they pay.

## A True Price Can Still Be a Manipulated Price

Expand Down
Loading