From 7c24ee82c9a38f18ff0238dee356fe38686da825 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Mon, 5 Oct 2026 18:23:37 +0000 Subject: [PATCH] Pre-print verification: fix what the third audit's edits broke A targeted pass checked every line the third audit's round changed against the companion, the figures and the rest of the book, then an independent agent re-checked every fix. The book follows the companion's lending fixes: liquidation takes no account a borrower controls and closes an emptied vault into the obligation, borrow rates are capped at 300% a year, a liquidation bonus must be payable at the threshold, and an update may raise a reserve's liquidation threshold but never lower it. An obligation a liquidation leaves with debt and no collateral stays open until it is repaid. Fixed: false generalizations about closing accounts and payout handlers, the liquidation gloss, test descriptions that claimed more than their tests assert, the perps accumulator definition, the options put sentence, the glossary's unsettled balance and base token entries, the conclusion's incentive sentence and its answer to the Introduction's responsibility promise, the order book's close section moved after the fee withdrawal its figure draws, its handler list spaced inside its box, and lending's obligation boxes showing what each handler stores rather than what a later refresh would write. PRINT-SPECIFICATION.md carries the final build: 311 pages with the cover and its blank reverse, 309 interior, a 0.696in spine and a 15.826 x 9.94in wrap. Two footnote calls moved so Paged.js no longer drops text at a page break. Lint, figure geometry and the full PDF build pass. Claude-Session: https://claude.ai/code/session_01UX53A6YR1Hjr8z6WzJxf2q --- AMAZON-DESCRIPTION.md | 2 +- PRINT-SPECIFICATION.md | 10 ++++---- README.md | 2 +- appendices/glossary.md | 20 +++++++--------- .../betting-market.md | 2 +- .../constant-product-amm.md | 4 ++-- .../escrow.md | 2 +- .../figures/amm-bob-swap.svg | 5 ++-- .../figures/escrow-swap-intent.svg | 2 +- .../figures/lending-borrow-ltv.svg | 4 ++-- .../lending-liquidation-second-call.svg | 7 +++--- .../figures/lending-liquidation.svg | 7 +++--- .../figures/lending-repay.svg | 4 ++-- .../figures/lending-short-borrow.svg | 4 ++-- .../figures/lending-short-collateral.svg | 4 ++-- .../figures/lending-withdraw-collateral.svg | 5 ++-- .../figures/order-book-alice-ask.svg | 18 +++++++------- .../figures/order-book-cancel.svg | 18 +++++++------- .../figures/order-book-initialize-market.svg | 18 +++++++------- .../figures/order-book-match.svg | 18 +++++++------- .../figures/order-book-settle.svg | 18 +++++++------- .../figures/order-book-state-machine.svg | 2 +- .../figures/order-book-withdraw-fees.svg | 18 +++++++------- .../fundraiser.md | 2 +- .../lending.md | 16 ++++++------- .../managed-fund.md | 4 ++-- .../options.md | 6 ++--- .../order-book.md | 24 +++++++++---------- .../perpetual-futures.md | 6 ++--- .../prop-amm.md | 4 ++-- conclusion/where-to-go-from-here.md | 6 ++--- .../intro.md | 6 ++--- .../operating-financial-software.md | 6 ++--- .../the-adversary.md | 4 ++-- .../the-arithmetic-of-money.md | 2 +- .../accounts-and-instruction-handlers.md | 4 ++-- .../figures/clock-decision-tree.svg | 2 +- .../figures/pda-key-value-store.svg | 2 +- solana-building-blocks/pdas.md | 2 +- solana-building-blocks/tokens.md | 6 ++--- 40 files changed, 146 insertions(+), 150 deletions(-) diff --git a/AMAZON-DESCRIPTION.md b/AMAZON-DESCRIPTION.md index 1579b5c7..2ef89454 100644 --- a/AMAZON-DESCRIPTION.md +++ b/AMAZON-DESCRIPTION.md @@ -14,7 +14,7 @@ Written for engineers shipping software that holds other people's assets, and fo In the tradition of *TCP/IP Illustrated*, the book teaches the layer that does not change. Frameworks churn, but the account model has been stable since Solana launched, the arithmetic of money will not change, and neither will the desire of adversaries to take your assets. -The book assumes no Solana experience, no finance background, and no programming. Solana and finance are built from first principles, with the people and the dollar amounts on the page, and every program is explained in prose and figures, with its Rust in the companion repository. +The book assumes no Solana experience, no finance background, and no programming. Solana and finance are both taught from first principles, with the people and the dollar amounts on the page, and every program is explained in prose and figures, with its Rust in the companion repository. The tested programs live in a companion repository, where continuous integration keeps them current in a way paper cannot, and a companion AI plugin applies the book's rules inside AI coding tools. An AI can write a Solana program on request. No AI can understand one on your behalf. diff --git a/PRINT-SPECIFICATION.md b/PRINT-SPECIFICATION.md index 6e70af11..5b3e8d6a 100644 --- a/PRINT-SPECIFICATION.md +++ b/PRINT-SPECIFICATION.md @@ -72,7 +72,7 @@ Headers and folios are suppressed on the cover, the title page, chapter openers ## Structure -The title page opens on a right-hand page: page one when the book is built without the cover, page three when it is built with it, the blank page between them being the reverse of the cover. A part title opens on a right-hand page, alone in the upper third of an otherwise empty page, and the reverse of that page is left blank so the part's first chapter also opens on a right-hand page. Every chapter opens on a right-hand page too, which is how a reader flipping through finds where one ends and the next begins. Paged.js fills the verso it skips with a blank page, so the rule costs a page whenever the section before ends on a right-hand page: seven before chapters and part titles in the October 5, 2026 build, a count that moves as chapters grow. Front-matter placeholders are exempt, because they share one flow until they have real content. +The title page opens on a right-hand page: page one when the book is built without the cover, page three when it is built with it, the blank page between them being the reverse of the cover. A part title opens on a right-hand page, alone in the upper third of an otherwise empty page, and the reverse of that page is left blank so the part's first chapter also opens on a right-hand page. Every chapter opens on a right-hand page too, which is how a reader flipping through finds where one ends and the next begins. Paged.js fills the verso it skips with a blank page, so the rule costs a page whenever the section before ends on a right-hand page: nine before chapters and part titles in the October 5, 2026 build, a count that moves as chapters grow. Front-matter placeholders are exempt, because they share one flow until they have real content. The title page and every part and chapter opening are checked against their page numbers after layout, and the build fails naming any that landed on a left-hand page. It prints the number it checked on success, because a selector that stopped matching would otherwise leave a check that passes without testing anything. @@ -90,15 +90,15 @@ Its art is 744 x 969, an aspect of 0.7678, which is the page's exactly, so nothi The drawing is vector throughout and its type is outlined, so the file embeds no fonts and nothing in it depends on a face being found at build time. The 300dpi rasterization is the only thing that limits it. An export from Figma carrying a layer blur, a non-normal blend mode, or a placed photograph would not have that property, because Figma flattens those regions to bitmap at frame resolution and its PDF export has no scale multiplier; such an export needs its frame scaled to 2232 x 2907 first, so the flattened parts land at 300dpi. -**The press cover is a separate export, sized by `npm run resize-cover`.** A file going to KDP wraps the whole book: 0.125in bleed + 7.44in back cover + spine + 7.44in front cover + 0.125in bleed wide, and 9.69 + 0.25in tall. The spine is the interior page count times KDP's per-page thickness (0.002252in for white paper, 0.0025in for cream), so at 308 interior pages on white (309 with the cover) the target is 15.824 x 9.94in with a 0.694in spine. None of that belongs in `assets/cover.pdf`, which is the front cover at trim and nothing else. +**The press cover is a separate export, sized by `npm run resize-cover`.** A file going to KDP wraps the whole book: 0.125in bleed + 7.44in back cover + spine + 7.44in front cover + 0.125in bleed wide, and 9.69 + 0.25in tall. The spine is the interior page count times KDP's per-page thickness (0.002252in for white paper, 0.0025in for cream), so at 309 interior pages on white (311 with the cover and its blank reverse) the target is 15.826 x 9.94in with a 0.696in spine. None of that belongs in `assets/cover.pdf`, which is the front cover at trim and nothing else. -`npm run resize-cover` rescales the Figma export of the full wrap, kept at `assets/cover-wrap.pdf` (another path can be passed as an argument, `--pages N` and `--paper white|cream` as options), to that exact size, filling the target and cropping overflow symmetrically into the bleed. Page count is read from the built book unless `--pages` is passed; pass the count of the file actually uploaded as the interior (a `NO_COVER=1` interior is one page shorter than the built book). The script also measures the spine the art was drawn for, by aligning trim heights and taking the width left over after two covers and two bleeds, and warns when that disagrees with the real spine: the art then bleeds onto or short of the true spine edges by half the difference per side, and spine text rides off-center by the same amount. A warning means the rescale is good enough for a proof and the production cover should be re-exported from a Figma frame drawn for the frozen page count (1 px = 1 pt: 15.824 x 9.94in is a 1139 x 716 px frame). +`npm run resize-cover` rescales the Figma export of the full wrap, kept at `assets/cover-wrap.pdf` (another path can be passed as an argument, `--pages N` and `--paper white|cream` as options), to that exact size, filling the target and cropping overflow symmetrically into the bleed. Page count is read from the built book unless `--pages` is passed; pass the count of the file actually uploaded as the interior (a `NO_COVER=1` interior is two pages shorter than the built book: the cover and its blank reverse). The script also measures the spine the art was drawn for, by aligning trim heights and taking the width left over after two covers and two bleeds, and warns when that disagrees with the real spine: the art then bleeds onto or short of the true spine edges by half the difference per side, and spine text rides off-center by the same amount. A warning means the rescale is good enough for a proof and the production cover should be re-exported from a Figma frame drawn for the frozen page count (1 px = 1 pt: 15.826 x 9.94in is a 1139 x 716 px frame). ## US Letter `--size letter` builds the same manuscript at 8.5 x 11in with 1.1in side margins, and writes it to a filename carrying the size so it cannot overwrite the print build. It is for reading on screen and for running a draft off an office printer, where Letter is the paper in the tray. -It is not a book size and is not what gets printed. Letter's margins leave a 6.3in measure, about 100 characters of 10pt type, which is longer than a reader tracks comfortably, and print on demand costs more per unit at that trim. The page count differs too: 267 pages against 309, because the larger page carries more. +It is not a book size and is not what gets printed. Letter's margins leave a 6.3in measure, about 100 characters of 10pt type, which is longer than a reader tracks comfortably, and print on demand costs more per unit at that trim. The page count differs too: 267 pages against 311, because the larger page carries more. ## Building It @@ -123,4 +123,4 @@ The switches compose: `NO_COVER=1 npm run build-pdf -- --size letter` is the cov The build needs a Chromium binary, found at `/opt/pw-browsers/chromium` or wherever `CHROMIUM_PATH` points. -At the time of writing the manuscript runs to **309 pages** with the cover, which puts it in KDP's 301 to 500 page band for gutter purposes, whose 0.625in minimum the 1in inside margin clears. Every figure is drawn, so that number will move with editing rather than with artwork. +At the time of writing the manuscript runs to **311 pages** with the cover, which puts it in KDP's 301 to 500 page band for gutter purposes, whose 0.625in minimum the 1in inside margin clears. Every figure is drawn, so that number will move with editing rather than with artwork. diff --git a/README.md b/README.md index 29a91be2..b887c118 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The book's home is [solanabook.org](https://solanabook.org), which hosts the pub ## Status -Drafted: the Introduction, Solana Building Blocks, all of Financial Programming Concepts in Solana, every program chapter, and the Glossary. Every figure is drawn as SVG in the chapters' `figures/` directories. The dedication, acknowledgements, and technical reviewers page are drafted. +Drafted: every chapter from the Introduction to *Where to Go From Here*, the Glossary, and the front and back matter. Every figure is drawn as SVG in the chapters' `figures/` directories. ## License diff --git a/appendices/glossary.md b/appendices/glossary.md index 1c230b8c..b9175db3 100644 --- a/appendices/glossary.md +++ b/appendices/glossary.md @@ -30,7 +30,7 @@ A fundraising rule under which contributions bind only if the full target is rea ### Alpenglow -Solana's consensus mechanism, which replaced Tower BFT. Under it the [commitment levels](#commitment-level) a client asks for are `processed` and `finalized`, and `confirmed` means the same as `finalized`. See the *Operating Financial Software* chapter. +Solana's consensus mechanism, which replaced Tower BFT. Under it the [commitment levels](#commitment-level) a client asks for are `processed` and `finalized`. See the *Operating Financial Software* chapter. ### American option / European option @@ -38,7 +38,7 @@ An American option may be exercised at any time up to its expiry; a European opt ### Anchor -A framework for writing Solana programs in Rust. A program declares the checks each account must pass as constraints, and Anchor generates the code that performs them, the [discriminator](#discriminator), and the [IDL](#idl). The companion repository ships every program in Anchor 1, Anchor 2, and Quasar versions; see the *Adversary* chapter for the checks. +A framework for writing Solana programs in Rust. A program declares the checks each account must pass as constraints, and Anchor generates the code that performs them, the [discriminator](#discriminator), and the [IDL](#idl). The companion repository ships the program behind each program chapter in Anchor 1, Anchor 2, and Quasar versions; see the *Adversary* chapter for the checks. ### arbitrage @@ -74,7 +74,7 @@ See [base token](#base-token). ### base token -The thing a market buys and sells (NVDAx), and the unit a **base amount** is denominated in: five NVDAx is a base amount. It is the first token of a [pair](#pair) written base/quote, and a price on the pair is how many units of the [quote token](#quote-token) buy one unit of the base. In the written pair NVDAx/USDC the base comes first, but the price is quoted in USDC per one NVDAx, so the base is the unit being priced, not the unit of the price. +The thing a market buys and sells (NVDAx), and the unit a **base amount** is denominated in: five NVDAx is a base amount. It is the first token of a [pair](#pair) written base/quote, and it is the unit being priced, not the unit of the price: a price on NVDAx/USDC is how many USDC, the [quote token](#quote-token), buy one NVDAx. ### basis point (bps) @@ -204,10 +204,6 @@ The number of decimal places in one major unit of a token, declared on the mint: A value at which a formula or a check stops meaning what it usually means, almost always zero or empty. An AMM reserve of zero lets the [K](#k) check pass vacuously (the *Constant-Product AMM* chapter); a price of zero or below means a broken price feed (the *Offchain Truth* chapter); an escrow offer with nothing on one side is refused by `make_offer()` with `ZeroAmount`. See [invariant](#invariant). -### discretization - -Representing a continuous quantity as discrete integer steps. Onchain amounts are integers in [minor units](#minor-unit), so there is no such thing as half a lamport, and every division must decide what happens to the fraction the result cannot represent. See [dust](#dust) and the *Arithmetic of Money* chapter. - ### discriminator The 8 bytes Anchor writes at the start of an account's data identifying its type, so a program cannot be fed one kind of account where another belongs. @@ -382,11 +378,11 @@ An order with a price it refuses to cross: buy at or below, sell at or above. ### liquidation -Forcibly repaying part of an unhealthy loan out of its collateral, at a bonus to the liquidator, to keep the market solvent. The close factor caps how much one call repays. +Forcibly repaying part of an unhealthy loan in return for some of its collateral at a bonus to the liquidator, to keep the market solvent. The close factor caps how much one call repays. ### liquidation bonus -The discount a liquidator receives on the collateral it seizes, which pays for the service of repaying an unhealthy loan. The *Lending and Borrowing* chapter's market sets it at 5% per reserve. +The discount a liquidator receives on the collateral they seize, which pays them for repaying an unhealthy loan. The *Lending and Borrowing* chapter's market sets it at 5% per reserve. ### liquidation threshold @@ -474,7 +470,7 @@ The total current value of everything a fund holds, computed from fresh prices. ### Newton's method (integer square root) -The iterative algorithm programs use to take a square root using only integers, converging in about seventy loop iterations at most for a 128-bit input. AMMs use it to mint initial LP tokens as the square root of the two reserves' product. See the *Arithmetic of Money* chapter. +The iterative algorithm programs use to take a square root using only integers, converging within seventy loop iterations for any product of two u64s. AMMs use it to mint initial LP tokens as the square root of the two reserves' product. See the *Arithmetic of Money* chapter. ### NFT (non-fungible token) @@ -646,7 +642,7 @@ What `saturating_sub` (and its `_add` and `_mul` siblings) does when a result wi ### seed -One of the inputs a program combines with its program ID to derive a [PDA](#pda-program-derived-address). The seeds are the key of a key/value store and the PDA is the address that key leads to. See the *PDAs* chapter. +One of the inputs a program combines with its program ID to derive a [PDA](#pda-program-derived-address). The seeds work like the lookup key of a database's key/value store, a different word from a private or public key, and the PDA is the address that lookup leads to. See the *PDAs* chapter. ### segmenting flow @@ -766,7 +762,7 @@ The wall-clock time in the Clock [sysvar](#sysvar), in whole seconds. The previo ### unsettled balance -An amount an exchange owes a trader, recorded as a counter on the trader's account rather than paid out at each fill, and claimed with one transfer later. See the *Order Book Exchange* chapter. +An amount an exchange owes a trader, recorded as a counter on the trader's account rather than paid out at each fill. The trader claims it later with `settle_funds()`, which pays out each token's balance in one transfer however many fills built it up. See the *Order Book Exchange* chapter. ### utilization diff --git a/building-financial-products-in-solana/betting-market.md b/building-financial-products-in-solana/betting-market.md index 3bffa760..be3b74d2 100644 --- a/building-financial-products-in-solana/betting-market.md +++ b/building-financial-products-in-solana/betting-market.md @@ -141,7 +141,7 @@ This chapter added six concepts: - **Terms locked before bettors commit stakes.** The outcome list, the fee, and the close time are all fixed before the first stake lands, so the question bettors staked on cannot be rewritten. - **Account closure as the double-claim defense.** A paid-out bet has no account left to claim with. - **Cancellation refunds every stake.** Draft and Open events can be canceled, and stakes in a canceled event are refunded in full, so a voided event strands nobody's stake. -- **Children close before parents.** Each close refuses while an account that names it is still open, so bets close before their outcomes and outcomes before their event, and counters on the event track what is still open. +- **Children close before parents.** `close_outcome()` refuses while any bet of the event is open, and `close_event()` refuses while any bet or outcome is, so bets close before outcomes and outcomes before their event; counters on the event track what is still open. In the *Order Book Exchange* chapter we will build an exchange. Instead of paying into one pool, traders post orders at the prices they want, backed by tokens moved into a vault when the order is placed, and the program matches each new order against the best resting one. You will see how orders are stored, matched, and paid out onchain, where every step costs compute. The pooled payout formula and the trusted signer both return later, in the *Lending and Borrowing* and *Perpetual Futures* chapters, where the trusted signature is a price feed and settlement runs continuously instead of once. diff --git a/building-financial-products-in-solana/constant-product-amm.md b/building-financial-products-in-solana/constant-product-amm.md index a83087d1..0958ad34 100644 --- a/building-financial-products-in-solana/constant-product-amm.md +++ b/building-financial-products-in-solana/constant-product-amm.md @@ -61,7 +61,7 @@ Bob is early and bullish. He runs `swap_tokens()` to spend 100 USDC on ACME, a t **The curve prices the trade.** It prices against Y plus the taxed input, 1,224.70, so X must fall to K ÷ 1,224.70 ≈ 459.3; the vault itself receives all 100 USDC. Bob receives about 40.7 ACME. He paid an *average* of about $2.46 per token while the quoted price before his trade was $2.25 and the spot price after it is about $2.67: his own trade moved along the curve as it executed. -**The invariant is re-checked.** After the transfers land, the handler reads both vault balances again and requires the new effective product to be at least the old one, failing with `InvariantViolated` otherwise. The program's own comment calls this the most important defensive check in the file: it catches any bug above it that would overpay a trader, the *Arithmetic of Money* chapter's rule to assert after the math. The check tolerates growth, because growth is just LP fees enriching the pool. +**The invariant is re-checked.** After the transfers land, the handler reads both vault balances again and requires the new effective product to be at least the old one, failing with `InvariantViolated` otherwise. The program's own comment calls this the most important defensive check in the file: a bug above it that overpays a trader by more than the LP fee is worth leaves the effective product below the old one, and the swap fails. That is the *Arithmetic of Money* chapter's rule to assert after the math. The check tolerates growth, because growth is just LP fees enriching the pool. The curve priced the trade with K held fixed. The 25 cents of LP fees it did not price against raised K once the input landed, so K after Bob's trade is about 562,615. @@ -142,7 +142,7 @@ The order book needed a tree, a matching engine, and maker accounts, because hum Seven concepts came with the curve: - **A formula as the market maker.** Output = taxed input × other reserve ÷ (this reserve + taxed input); the first deposit sets the price, and every trade pays its own price impact. -- **How K changes.** Held fixed while a trade is priced, raised by the LP fee once the input lands. +- **How K changes.** Deposits raise it without moving the price and withdrawals lower it; during a swap it is held fixed while the trade is priced, then raised by the LP fee once the input lands. - **A product check needs both factors positive.** If either is zero, the product is zero and any trade passes the check. - **Assert after the math.** The post-trade K check re-reads both vaults after the transfers; the *Arithmetic of Money* chapter's rule, applied to a swap. - **Effective reserves.** The raw vault balance minus the admin's owed slice, which is what every piece of LP-facing math uses. diff --git a/building-financial-products-in-solana/escrow.md b/building-financial-products-in-solana/escrow.md index 3625b0bb..6264e06d 100644 --- a/building-financial-products-in-solana/escrow.md +++ b/building-financial-products-in-solana/escrow.md @@ -1,6 +1,6 @@ # Chapter 11: Escrow -Escrow lets two people swap tokens without trusting each other or paying a middleman, with three instruction handlers. It also teaches program custody, terms stored in an account, a closing handler for every state, and amounts counted in minor units, which every later program reuses. +Escrow lets two people swap tokens without trusting each other or paying a middleman, with three instruction handlers. It also teaches what later programs reuse: a vault only the program can sign for, terms stored in an account, a closing handler for every state, and amounts counted in minor units. The chapters that follow add what escrow does not have: a formula that sets a price, an outcome that decides a payout, time and interest, and an operator who must be constrained. diff --git a/building-financial-products-in-solana/figures/amm-bob-swap.svg b/building-financial-products-in-solana/figures/amm-bob-swap.svg index 6bdfaf8b..6cb2dabe 100644 --- a/building-financial-products-in-solana/figures/amm-bob-swap.svg +++ b/building-financial-products-in-solana/figures/amm-bob-swap.svg @@ -1,4 +1,4 @@ - + Bob's swap moves the price @@ -198,8 +198,7 @@ ≈ 40.7 ACME signed by the pool config - effective X × Y ≈ 562,615: K held fixed while the trade was priced, - then raised by the LPs' 25 cents once the input landed + effective X × Y ≈ 562,615 after the trade diff --git a/building-financial-products-in-solana/figures/escrow-swap-intent.svg b/building-financial-products-in-solana/figures/escrow-swap-intent.svg index 286a3ac0..615e40fc 100644 --- a/building-financial-products-in-solana/figures/escrow-swap-intent.svg +++ b/building-financial-products-in-solana/figures/escrow-swap-intent.svg @@ -15,7 +15,7 @@ Bob 1 SOL - offers 1 TSLAx + holds 1 TSLAx wants 250 USDC diff --git a/building-financial-products-in-solana/figures/lending-borrow-ltv.svg b/building-financial-products-in-solana/figures/lending-borrow-ltv.svg index 9262bf64..382e5bc5 100644 --- a/building-financial-products-in-solana/figures/lending-borrow-ltv.svg +++ b/building-financial-products-in-solana/figures/lending-borrow-ltv.svg @@ -77,8 +77,8 @@ obligation - deposited_value: $1,000 - borrowed_value: $750 + borrows: 750 USDC + stale: true diff --git a/building-financial-products-in-solana/figures/lending-liquidation-second-call.svg b/building-financial-products-in-solana/figures/lending-liquidation-second-call.svg index 4fc8ce2a..546ac5b3 100644 --- a/building-financial-products-in-solana/figures/lending-liquidation-second-call.svg +++ b/building-financial-products-in-solana/figures/lending-liquidation-second-call.svg @@ -1,4 +1,4 @@ - + Dave liquidates Carol a second time @@ -66,8 +66,8 @@ obligation - deposited_value: $291.25 - borrowed_value: $225 + borrows: 0.75 SPCXx + stale: true @@ -321,4 +321,5 @@ not drawn: the price feeds and share mints this step reads but does not change, and Maria's USDC, NVDAx and SPCXx accounts and her three share accounts, which it does not touch; balances and supplies leave out Maria's opening deposit into each reserve, 1,001 minor units in and one minor unit of shares out; the USDC reserve leaves out the interest on Bob's loan, as the walkthrough does + the obligation's remaining deposit is the share count its collateral vault holds diff --git a/building-financial-products-in-solana/figures/lending-liquidation.svg b/building-financial-products-in-solana/figures/lending-liquidation.svg index f6e9378c..a6af4ee9 100644 --- a/building-financial-products-in-solana/figures/lending-liquidation.svg +++ b/building-financial-products-in-solana/figures/lending-liquidation.svg @@ -1,4 +1,4 @@ - + Dave liquidates Carol @@ -66,8 +66,8 @@ obligation - deposited_value: $527.50 - borrowed_value: $450 + borrows: 1.5 SPCXx + stale: true @@ -321,4 +321,5 @@ not drawn: the price feeds and share mints this step reads but does not change, and Maria's USDC, NVDAx and SPCXx accounts and her three share accounts, which it does not touch; balances and supplies leave out Maria's opening deposit into each reserve, 1,001 minor units in and one minor unit of shares out; the USDC reserve leaves out the interest on Bob's loan, as the walkthrough does + the obligation's remaining deposit is the share count its collateral vault holds diff --git a/building-financial-products-in-solana/figures/lending-repay.svg b/building-financial-products-in-solana/figures/lending-repay.svg index 64a8e17d..1cf654fc 100644 --- a/building-financial-products-in-solana/figures/lending-repay.svg +++ b/building-financial-products-in-solana/figures/lending-repay.svg @@ -104,8 +104,8 @@ obligation - deposited_value: $1,000 - borrowed_value: $0 + borrows: none + stale: true diff --git a/building-financial-products-in-solana/figures/lending-short-borrow.svg b/building-financial-products-in-solana/figures/lending-short-borrow.svg index 40c8ed11..66106ed7 100644 --- a/building-financial-products-in-solana/figures/lending-short-borrow.svg +++ b/building-financial-products-in-solana/figures/lending-short-borrow.svg @@ -156,8 +156,8 @@ obligation - deposited_value: $1,000 - borrowed_value: $750 + borrows: 3 SPCXx + stale: true diff --git a/building-financial-products-in-solana/figures/lending-short-collateral.svg b/building-financial-products-in-solana/figures/lending-short-collateral.svg index 47d12c39..88624042 100644 --- a/building-financial-products-in-solana/figures/lending-short-collateral.svg +++ b/building-financial-products-in-solana/figures/lending-short-collateral.svg @@ -147,8 +147,8 @@ obligation - deposited_value: $1,000 - borrowed_value: $0 + deposits: 1,000 USDC shares + stale: true diff --git a/building-financial-products-in-solana/figures/lending-withdraw-collateral.svg b/building-financial-products-in-solana/figures/lending-withdraw-collateral.svg index d6f6c383..25597ab0 100644 --- a/building-financial-products-in-solana/figures/lending-withdraw-collateral.svg +++ b/building-financial-products-in-solana/figures/lending-withdraw-collateral.svg @@ -104,8 +104,8 @@ obligation - deposited_value: $0 - borrowed_value: $0 + deposits: none + stale: true @@ -122,6 +122,7 @@ + closed: rent refunds to Bob diff --git a/building-financial-products-in-solana/figures/order-book-alice-ask.svg b/building-financial-products-in-solana/figures/order-book-alice-ask.svg index c983e0f4..0ed3399a 100644 --- a/building-financial-products-in-solana/figures/order-book-alice-ask.svg +++ b/building-financial-products-in-solana/figures/order-book-alice-ask.svg @@ -167,15 +167,15 @@ order book program initialize_market() - initialize_market_user() - place_order() - cancel_order() - settle_funds() - close_order() - close_market_user() - withdraw_fees() - pause_market() - resume_market() + initialize_market_user() + place_order() + cancel_order() + settle_funds() + close_order() + close_market_user() + withdraw_fees() + pause_market() + resume_market() diff --git a/building-financial-products-in-solana/figures/order-book-cancel.svg b/building-financial-products-in-solana/figures/order-book-cancel.svg index 6aa3bfa2..c9fb4d77 100644 --- a/building-financial-products-in-solana/figures/order-book-cancel.svg +++ b/building-financial-products-in-solana/figures/order-book-cancel.svg @@ -180,15 +180,15 @@ order book program initialize_market() - initialize_market_user() - place_order() - cancel_order() - settle_funds() - close_order() - close_market_user() - withdraw_fees() - pause_market() - resume_market() + initialize_market_user() + place_order() + cancel_order() + settle_funds() + close_order() + close_market_user() + withdraw_fees() + pause_market() + resume_market() diff --git a/building-financial-products-in-solana/figures/order-book-initialize-market.svg b/building-financial-products-in-solana/figures/order-book-initialize-market.svg index 64931db0..6914c359 100644 --- a/building-financial-products-in-solana/figures/order-book-initialize-market.svg +++ b/building-financial-products-in-solana/figures/order-book-initialize-market.svg @@ -117,15 +117,15 @@ order book program initialize_market() - initialize_market_user() - place_order() - cancel_order() - settle_funds() - close_order() - close_market_user() - withdraw_fees() - pause_market() - resume_market() + initialize_market_user() + place_order() + cancel_order() + settle_funds() + close_order() + close_market_user() + withdraw_fees() + pause_market() + resume_market() diff --git a/building-financial-products-in-solana/figures/order-book-match.svg b/building-financial-products-in-solana/figures/order-book-match.svg index e5e9a230..c329cf4b 100644 --- a/building-financial-products-in-solana/figures/order-book-match.svg +++ b/building-financial-products-in-solana/figures/order-book-match.svg @@ -195,15 +195,15 @@ order book program initialize_market() - initialize_market_user() - place_order() - cancel_order() - settle_funds() - close_order() - close_market_user() - withdraw_fees() - pause_market() - resume_market() + initialize_market_user() + place_order() + cancel_order() + settle_funds() + close_order() + close_market_user() + withdraw_fees() + pause_market() + resume_market() diff --git a/building-financial-products-in-solana/figures/order-book-settle.svg b/building-financial-products-in-solana/figures/order-book-settle.svg index 0040ab7d..c13dbef6 100644 --- a/building-financial-products-in-solana/figures/order-book-settle.svg +++ b/building-financial-products-in-solana/figures/order-book-settle.svg @@ -182,15 +182,15 @@ order book program initialize_market() - initialize_market_user() - place_order() - cancel_order() - settle_funds() ×2 - close_order() - close_market_user() - withdraw_fees() - pause_market() - resume_market() + initialize_market_user() + place_order() + cancel_order() + settle_funds() ×2 + close_order() + close_market_user() + withdraw_fees() + pause_market() + resume_market() diff --git a/building-financial-products-in-solana/figures/order-book-state-machine.svg b/building-financial-products-in-solana/figures/order-book-state-machine.svg index d93d21ff..01af33cc 100644 --- a/building-financial-products-in-solana/figures/order-book-state-machine.svg +++ b/building-financial-products-in-solana/figures/order-book-state-machine.svg @@ -31,7 +31,7 @@ cancel_order() or eviction terminal; close_order() returns rent - leaf out; close_order() returns rent + terminal; close_order() returns rent resting in the tree diff --git a/building-financial-products-in-solana/figures/order-book-withdraw-fees.svg b/building-financial-products-in-solana/figures/order-book-withdraw-fees.svg index fba4e0ff..46adcae8 100644 --- a/building-financial-products-in-solana/figures/order-book-withdraw-fees.svg +++ b/building-financial-products-in-solana/figures/order-book-withdraw-fees.svg @@ -169,15 +169,15 @@ order book program initialize_market() - initialize_market_user() - place_order() - cancel_order() - settle_funds() - close_order() - close_market_user() - withdraw_fees() - pause_market() - resume_market() + initialize_market_user() + place_order() + cancel_order() + settle_funds() + close_order() + close_market_user() + withdraw_fees() + pause_market() + resume_market() diff --git a/building-financial-products-in-solana/fundraiser.md b/building-financial-products-in-solana/fundraiser.md index 6f037e95..cdae6a86 100644 --- a/building-financial-products-in-solana/fundraiser.md +++ b/building-financial-products-in-solana/fundraiser.md @@ -126,7 +126,7 @@ The suite warps the clock with a helper that advances whole days, and asserts ba - `test_refund_after_deadline_target_not_met_succeeds` follows the losing timeline to a clean refund. - `test_close_fundraiser_after_failed_raise_allows_a_new_raise` closes the failed fundraiser and checks the same wallet can raise again. -The remaining tests each call a handler in a state where it must refuse, and assert the error, such as `test_refund_before_deadline_fails`. `test_close_fundraiser_sweeps_direct_donations_to_maker` checks that `close_fundraiser()` pays tokens transferred straight into the vault to the maker. +Most of the remaining tests call a handler in a state where it must refuse, and assert the error, such as `test_refund_before_deadline_fails`. `test_close_fundraiser_sweeps_direct_donations_to_maker` mints tokens straight into the vault and checks that `close_fundraiser()` pays them to the maker. ## What You Learnt from Building the Fundraiser diff --git a/building-financial-products-in-solana/lending.md b/building-financial-products-in-solana/lending.md index ba6fcb62..01046014 100644 --- a/building-financial-products-in-solana/lending.md +++ b/building-financial-products-in-solana/lending.md @@ -30,10 +30,10 @@ Each reserve carries a `ReserveConfig`, all in basis points, and its validation | --- | --- | --- | | `loan_to_value_bps` | 75% | How much of your collateral's value you may borrow. | | `liquidation_threshold_bps` | 80% | The debt level at which your position becomes liquidatable. `validate()` refuses a loan-to-value above it: you cannot be allowed to borrow past the point you would be liquidated. | -| `liquidation_bonus_bps` | 5% | The liquidator's discount. | +| `liquidation_bonus_bps` | 5% | The liquidator's discount. `validate()` refuses a bonus the collateral could not pay at the threshold, where threshold × (1 + bonus) exceeds 100% (`LiquidationBonusUnpayable`, `rejects_unpayable_liquidation_bonus_at_initialize`). | | `close_factor_bps` | 50% | How much of a debt one liquidation may repay. Zero is rejected, because it would make every liquidation a no-op. | | `reserve_factor_bps` | 10% | The house's cut of interest. | -| `min/optimal/max_borrow_rate_bps` | 2% / 20% / 150% | The interest curve, kinked at `optimal_utilization_bps` (80%), which must sit strictly inside (0%, 100%). | +| `min/optimal/max_borrow_rate_bps` | 2% / 20% / 150% | The interest curve, kinked at `optimal_utilization_bps` (80%), which must sit strictly inside (0%, 100%). `validate()` refuses any rate above 300% a year (`BorrowRateAboveCeiling`). | | `max_confidence_bps` | 1% | The widest confidence band the reserve will still value against, as a fraction of the price. Zero is rejected, because no live price feed reports a band of zero, so the reserve could never be valued and every obligation holding the asset would be frozen. Above 100% is rejected, as it is for the other six ratios in the table; only the three borrow rates may exceed it, and this market's maximum does. | ## Supplying: Tokens In, Shares Out @@ -57,7 +57,7 @@ Bob needs cash but does not want to sell his five NVDAx (NVIDIA stock, $200 each When `refresh_obligation()` values Bob's position, collateral is floored and debt ceiled, so the check errs against him. Borrowing one unit past the allowance fails (`BorrowTooLarge`), and the test `borrow_up_to_max_ltv_then_one_more_fails` checks the boundary from both sides. Bob's NVDAx is not sold, and he gets it back the moment he repays. -The second step created Bob's **obligation**, one account per borrower per market (PDA seeds: `"obligation"`, the market, the owner): his loan file, listing collateral deposits and borrows (up to four reserves each). It also caches values (`deposited_value`, `borrowed_value`, `allowed_borrow_value`, `unhealthy_borrow_value`) behind a `stale` flag that forces revaluation before anything financial happens. Once it holds no collateral and no debt, its owner can close it with `close_obligation()` and take its rent back; one that still holds either is refused with `ObligationNotEmpty`. +The second step created Bob's **obligation**, one account per borrower per market (PDA seeds: `"obligation"`, the market, the owner): his loan file, listing collateral deposits and borrows (up to four reserves each). It also caches values (`deposited_value`, `borrowed_value`, `allowed_borrow_value`, `unhealthy_borrow_value`) behind a `stale` flag that forces revaluation before any handler judges the position's health. Once it holds no collateral and no debt, its owner can close it with `close_obligation()` and take its rent back; one that still holds either is refused with `ObligationNotEmpty`. ![](figures/lending-borrow-ltv.svg) @@ -124,7 +124,7 @@ Five weeks in, Bob no longer needs the cash. His $750 debt has become about $757 ![](figures/lending-repay.svg) -Then `withdraw_obligation_collateral()` releases his NVDAx shares. Bob owes nothing now, so the handler reads no price and needs no refresh: a borrower with no debt can take their collateral out while the price feed is stale or silent. While a debt remains, the withdraw math rounds *up* the borrow power being removed, so a withdrawal can never slip past the health check by a rounding sub-unit. Bob's withdrawal empties his collateral vault, so the handler moves the vault's whole balance to him and closes it, returning the rent he paid when it was created. A liquidation that seizes all of one collateral closes that vault the same way, with the rent still going to the borrower: `liquidate_obligation()` takes an `obligation_owner` account for it and checks it against the obligation. +Then `withdraw_obligation_collateral()` releases his NVDAx shares. Bob owes nothing now, so the handler reads no price and needs no refresh: a borrower with no debt can take their collateral out while the price feed is stale or silent. While a debt remains, the withdraw math rounds *up* the borrow power being removed, so a withdrawal can never slip past the health check by a rounding sub-unit. Bob's withdrawal empties his collateral vault, so the handler moves the vault's whole balance to him and closes it, returning the rent he paid when it was created. A liquidation that seizes all of one reserve's collateral closes that vault too, but puts its rent into the obligation account, so the liquidation takes no account the borrower controls and nothing the borrower does to their wallet can make it fail. `close_obligation()` later returns that rent to the owner with the obligation's own. An obligation a liquidation leaves with debt and no collateral stays open until someone repays the debt, since `close_obligation()` refuses while debt remains (`close_obligation_refused_while_debt_remains_after_full_liquidation`). ![](figures/lending-withdraw-collateral.svg) @@ -167,13 +167,13 @@ Because every division rounds this way, the market never loses a minor unit to r - **A wrong price makes every number wrong.** Every health check multiplies by the price feed, so a bad price means wrong collateral values, wrong debts, and liquidations that should not happen. This program refuses stale prices (`borrow_with_stale_price_feed_is_rejected`), refuses prices whose confidence band is wider than the reserve's `max_confidence_bps` (`borrow_against_collateral_priced_with_a_wide_band_is_rejected`, `borrow_of_a_token_priced_with_a_wide_band_is_rejected`), and lets only the market owner write them (`non_owner_cannot_write_market_price_feed`). Manipulated prices have produced nine-figure losses on real markets (the price-manipulation section of the *Adversary* chapter covers Mango Markets). - **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, a confidence limit of zero or above the price (`rejects_ltv_above_liquidation_threshold`, `rejects_misordered_interest_rate_curve`, `rejects_zero_confidence_limit`, `rejects_confidence_limit_wider_than_the_price`). -- **Share inflation through the reserve's own borrowing.** Total liquidity 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 a single minor unit of its shares, 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 attacker's unit of shares 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 that unit is worth about 690 USDC, a 1,000 USDC deposit mints one unit of shares, and the attacker's unit 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 would keep about 155 USDC of the victim's deposit. With them, the attacker's unit is one of 1,001, and nearly everything the rounding leaves behind goes to shares nobody can redeem. The test asserts that the attacker ends with less than they started with and the victim gets back at least 99.9% of the 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: it is only possible while unhealthy and is economically pointless. +- **Dangerous configurations are rejected at write time**: LTV above threshold, zero close factor, misordered rate curves, a rate above 300% a year, a confidence limit of zero or above the price (`rejects_ltv_above_liquidation_threshold`, `rejects_misordered_interest_rate_curve`, `rejects_borrow_rate_above_ceiling_on_update`, `rejects_zero_confidence_limit`, `rejects_confidence_limit_wider_than_the_price`). `update_reserve_config()` also refuses a lower liquidation threshold with `RiskLimitLowered`, so an update cannot pull the liquidation threshold down under an open borrow (`rejects_lowering_liquidation_threshold`). It accepts a lower loan-to-value, down to zero to stop new borrowing (`accepts_lowering_loan_to_value`). +- **Share inflation through the reserve's own borrowing.** Total liquidity 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 a single minor unit of its shares, 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 attacker's unit of shares 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 that unit is worth about 690 USDC, a 1,000 USDC deposit mints one unit of shares, and the attacker's unit 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 would keep about 155 USDC of the victim's deposit. With them, the attacker's unit is one of 1,001, and nearly everything the rounding leaves behind goes to shares nobody can redeem. The test asserts that the attacker ends with no more than they started with and the victim gets back at least 99.9% of the 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 (`owner_can_liquidate_their_own_obligation`): it is only possible while unhealthy and is economically pointless. ## 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 tests both sides of the boundary. 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). Then liquidation: healthy rejected, unhealthy corrected with bonus and close factor, over-seizure refused, a seizure of all the collateral that closes the vault (`seizing_all_collateral_closes_the_vault_and_returns_its_rent_to_the_owner`), and a liquidator who names themselves to receive its rent refused (`liquidator_cannot_redirect_the_vault_rent`). The borrowing tests cover the loan-to-value boundary, a skipped obligation refresh, a stale price, a price from before a restart, and a confidence band too wide on the collateral or on the borrowed token; `confidence_band_at_the_limit_passes_and_one_unit_over_fails` checks the band from both sides of the limit. `debt_free_withdraw_needs_no_price_and_no_refresh` and `withdraw_after_full_repay_needs_no_price` let the price go stale and take the collateral out anyway, and `withdraw_with_debt_is_refused_while_the_price_is_stale` checks the other side. Closing has its own file: an obligation closes only when empty and only for its owner (`close_obligation_with_collateral_is_refused`, `close_obligation_with_debt_is_refused`, `non_owner_cannot_close_obligation`), a full withdrawal closes the vault and returns its rent (`full_withdraw_closes_the_vault_and_returns_its_rent`), and shares donated to the vault cannot keep it open (`donated_shares_cannot_keep_the_vault_open`). Reserve configuration has its own file, dangerous parameters refused at write time, a confidence limit of zero or above the price among them. The security tests cover 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 tests both sides of the boundary. 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). Then liquidation: healthy rejected, unhealthy corrected with bonus and close factor, over-seizure refused, and a seizure of all the collateral that closes the vault and puts its rent in the obligation (`seizing_all_collateral_closes_the_vault_into_the_obligation`). That rent returns to the owner with the obligation's own when the owner closes it after the remaining debt is repaid (`close_obligation_after_full_liquidation_returns_both_rents_to_the_owner`). The borrowing tests cover the loan-to-value boundary, a skipped obligation refresh, a stale price, a price from before a restart, and a confidence band too wide on the collateral or on the borrowed token; `confidence_band_at_the_limit_passes_and_one_unit_over_fails` checks the band from both sides of the limit. `debt_free_withdraw_needs_no_price_and_no_refresh` and `withdraw_after_full_repay_needs_no_price` let the price go stale and take the collateral out anyway, and `withdraw_with_debt_is_refused_while_the_price_is_stale` checks the other side. Closing has its own file: an obligation closes only when empty and only for its owner (`close_obligation_with_collateral_is_refused`, `close_obligation_with_debt_is_refused`, `non_owner_cannot_close_obligation`), a full withdrawal closes the vault and returns its rent (`full_withdraw_closes_the_vault_and_returns_its_rent`), and shares donated to the vault cannot keep it open (`donated_shares_cannot_keep_the_vault_open`). Reserve configuration has its own file: dangerous parameters are refused at write time, among them a confidence limit of zero or above the price and a rate one past the ceiling, and so is an update that lowers the liquidation threshold. The security tests cover 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 diff --git a/building-financial-products-in-solana/managed-fund.md b/building-financial-products-in-solana/managed-fund.md index 932e3b5b..236e8f02 100644 --- a/building-financial-products-in-solana/managed-fund.md +++ b/building-financial-products-in-solana/managed-fund.md @@ -10,7 +10,7 @@ In a traditional fund, "the manager cannot run off with client assets" is enforc Two different people hold power in this program, and their separation is its first security decision.[^separate-people] Victor runs the registry: he is not a fund manager, but the curator who decides which assets managers may invest in at all, and which Pyth price feed the program reads for each. He operates the platform the funds live on, and his business is his reputation for funds not failing, so vetting price feeds is part of the service his platform offers. Maria, the fund manager you will meet shortly, gets to pick allocations only from what Victor has approved. -Victor runs `initialize_registry()` once, creating the **registry**, an account whose only content is the `authority` allowed to approve assets under it. Victor then runs `approve_asset()` for each asset he vets, and each call creates an **`ApprovedAsset`** account (PDA seeds: `"approved_asset"`, the registry, the mint), binding that mint to its official Pyth price feed. An asset is approved if it has an `ApprovedAsset` account, and not approved if it does not. Victor vets which assets and which price feeds are safe, so a manager cannot list a token they minted themselves, and cannot pair a real asset with a price feed they control. When Maria later adds an asset to her fund, the price feed address is copied out of the `ApprovedAsset` account; the manager never supplies a price feed. +Victor runs `initialize_registry()` once, creating the **registry**, an account that records the `authority` allowed to approve assets under it. Victor then runs `approve_asset()` for each asset he vets, and each call creates an **`ApprovedAsset`** account (PDA seeds: `"approved_asset"`, the registry, the mint), binding that mint to its official Pyth price feed. An asset is approved if it has an `ApprovedAsset` account, and not approved if it does not. Victor vets which assets and which price feeds are safe, so a manager cannot list a token they minted themselves, and cannot pair a real asset with a price feed they control. When Maria later adds an asset to her fund, the price feed address is copied out of the `ApprovedAsset` account; the manager never supplies a price feed. The approved set is stocks, bonds, whatever investors actually want. It cannot include the manager's own token: `add_asset()` checks approval by deriving the `ApprovedAsset` address for the mint it is handed, so adding an unapproved asset fails because the account does not exist (checked by `test_add_asset_rejects_unapproved`). @@ -106,7 +106,7 @@ All of them use u128 intermediates and multiply before dividing. The four floore - **Nobody can churn the fund.** Churn is trading a fund back and forth for no investment reason, so that it pays slippage on every leg. If the manager chose the rebalancing trades, they could sell a balanced fund's NVDAx for TSLAx and back again, each leg filling up to 1% below the oracle price. The fund would end each round trip at 40/60 and poorer, through trades every rule allowed. That is not only carelessness: the value lost goes to whoever fills the trades, and a manager who controls when and how much the fund trades can arrange to be that party. Here no caller chooses a trade. `test_rebalance_cannot_churn` rebalances once and checks that a second call, in either direction, is refused. - **Every number in the fund depends on the price feed.** `load_price()` refuses the five bad prices the deposit section lists, so no deposit or rebalance runs on a bad price. The restart check is there because after a halt the timestamp trails real time, so a price from before the halt can still look under a minute old, and the *Time* chapter explains why. The *Offchain Truth* chapter explains what confidence means and why "fresh but uncertain" is its own failure mode. A deposit is priced from the price feed and a rebalance sets its slippage floor from it, so a band wider than the fund's 1% slippage tolerance would let a trade fill outside that tolerance. Withdrawal reads no price, so while deposits and rebalances wait for the band to narrow, investors can still take their slice in kind. `test_wide_confidence_price_rejected` widens NVDAx's band to 2% of its price and checks that deposit and rebalance are refused, that withdrawal still pays out, and that a band of exactly 1% is accepted. - **Donations are ignored, which prevents the inflation attack.** Anyone can transfer tokens straight into a vault. A program that valued the fund from its vaults' balances is open to the inflation attack from the *Adversary* chapter. This program values the fund from `usdc_holdings` and `asset_holdings`, so a donation changes a balance and nothing the handlers read. Run the *Adversary* chapter's numbers against it: after the attacker's one minor unit and 1,000 USDC donation, the next depositor's 1,000 USDC still buys 1,000 shares. The donation stays in the vault, and no withdrawal ever pays it out. Nor can a donation force a rebalance or pay for one: the handler values the recorded holdings, so donated tokens cannot push an asset over its target, and the buy leg spends only what the sale brought in (`test_rebalance_ignores_donations`). -- **A deposit too small to buy anything is refused.** Ignoring donations leaves one gap: a deposit so small that a swap takes its USDC and returns none of the asset. It would mint shares against a fund recorded as worth nothing, and every later deposit would then divide by zero, so it fails with `DepositTooSmall`. A depositor's `minimum_shares` floor protects only a depositor whose wallet sets it, as the *Adversary* chapter says, so the program does not rely on it. It remains for what neither check covers, a share price that moved between signing and landing. The empty-pool section of the *Adversary* chapter compares this defense with the others. +- **A deposit too small to buy anything is refused.** Ignoring donations leaves one gap: a deposit so small that a swap takes its USDC and returns none of the asset. It would mint shares against a fund recorded as worth nothing, and every later deposit would then divide by zero, so it fails with `DepositTooSmall`. A depositor's `minimum_shares` floor would also stop the inflation attack, but only for a depositor whose wallet sets it, as the *Adversary* chapter says, so the program does not rely on it. The floor remains for what ignoring donations and `DepositTooSmall` do not cover: a share price that moved between signing and landing. The empty-pool section of the *Adversary* chapter compares this defense with the others. - **Partial baskets are refused**, on deposit, withdrawal, and rebalance alike (`IncompleteAssetAccounts`). ## Testing Considerations diff --git a/building-financial-products-in-solana/options.md b/building-financial-products-in-solana/options.md index 952db312..b64debc6 100644 --- a/building-financial-products-in-solana/options.md +++ b/building-financial-products-in-solana/options.md @@ -34,7 +34,7 @@ A sold option has exactly two endings: the holder exercises before expiry, or th ## A Call Pays Off When the Price Rises, and a Put When It Falls -A put is a call with the two sides swapped: the writer posts the strike instead of the shares, and the holder sells instead of buys. Each row of the table gives the call's version and the put's, and the rest of this chapter follows one of each: Alice's call and Carol's put. +A put is a call with the two tokens swapped: the writer posts the strike instead of the shares, and the holder sells the underlying instead of buying it. Each row of the table gives the call's version and the put's, and the rest of this chapter follows one of each: Alice's call and Carol's put. > **A "contract" here is an option, not a Solana program.** Other chains call a program a "smart contract". In this chapter the word always means the option, which the program records as an `OptionContract` account. > @@ -81,7 +81,7 @@ The **market** account lives at a PDA with seeds `"market"`, the underlying mint - `quote_owed`: how much of the quote vault the market owes, as put writers' collateral and as call holders' strike payments waiting to be collected. - `fees_owed`: how much of the quote vault belongs to the admin. -The market PDA is the authority of the **underlying vault** and the **quote vault** (seeds `"underlying_vault"` and `"quote_vault"` + market), and the program signs for them with the market's seeds. Both vaults hold other people's tokens, mixed: the underlying vault holds every call writer's shares together, and the quote vault holds every put writer's cash, every exercised call's strike payment, and the venue's fees together. The three counters record how much of each vault is owed. Every handler updates them first, and before every transfer `check_custody()` checks that each vault will still hold at least that much once the transfer lands. +The market PDA is the authority of the **underlying vault** and the **quote vault** (seeds `"underlying_vault"` and `"quote_vault"` + market), and the program signs for them with the market's seeds. Both vaults hold other people's tokens, mixed: the underlying vault holds every call writer's shares together, and the quote vault holds every put writer's cash, every exercised call's strike payment, and the venue's fees together. The three counters record how much of each vault is owed. Every handler that moves tokens updates them before its transfers, and `check_custody()` then checks that each vault will still hold at least that much once the transfers land. ## A Writer Posts the Whole Obligation and Names a Price @@ -229,7 +229,7 @@ The companion repository's `kani-proofs` crate model-checks the custody ledger, - **The clock.** Expiry is a Unix timestamp, and the network's clock can drift from wall-clock time by seconds. A holder who waits until the final second to exercise risks that drift putting the exercise after expiry, and the program cannot help them. The boundary itself is tested from both sides (`test_exercise_is_allowed_up_to_but_not_at_expiry`, `test_reclaim_is_refused_before_expiry`). - **A writer blocks the exercise.** The strike goes into the vault, as the exercise section explains, so no token account of the writer's is on the exercise path. - **The wrong account carries the premium.** The buyer pays the writer directly, so the destination has to be bound to the writer or a buyer could name their own account and buy the option for free. The Anchor build derives it as the writer's associated token account, as the purchase step above shows; the Quasar build, whose token accounts are not associated token accounts, checks the account's owner and mint in the handler, and `buy_refuses_a_premium_account_the_writer_does_not_own` checks it. -- **What is absent.** Most of the usual attacks on a financial program do not apply here. There is no margin, so there is no oracle to value it and no liquidator to close it. There are no shares to inflate, and there is no rounding in settlement to drain, since the option stores the two amounts it moves. What is left is the custody ledger and the two comparisons against the clock, which is why both are model-checked as well as tested. +- **What is absent.** Most of the usual attacks on a financial program do not apply here. There is no margin, so there is no oracle to value a position against it and no liquidator to close a position it no longer covers. There are no shares to inflate, and there is no rounding in settlement to drain, since the option stores the two amounts it moves. What is left is the custody ledger and the two comparisons against the clock, which is why both are model-checked as well as tested. ## Testing Considerations diff --git a/building-financial-products-in-solana/order-book.md b/building-financial-products-in-solana/order-book.md index 16e97f51..49086f88 100644 --- a/building-financial-products-in-solana/order-book.md +++ b/building-financial-products-in-solana/order-book.md @@ -28,11 +28,11 @@ A critbit tree does not balance itself, so an attacker can place asks at 2, 4, 8 The market this chapter builds trades NVDAx over USDC: NVDAx, a stock, is the **base token**, the thing being bought and sold; USDC is the **quote token**, the thing prices are stated in. You quote in the stable asset so that "250" always means dollars. -Maria runs the exchange. She is the market authority: she creates the market, and she alone can pause it, resume it, and withdraw its fees. Her revenue is a fee of 25 basis points, a quarter of one percent, deducted from each fill's quote proceeds when orders match. In each fill the **maker** is the order that was already resting on the book and the **taker** is the arriving order that crossed it. So the side of the fill that receives the quote token bears it: the maker when the taker is a bid, the taker when the taker is an ask. +Maria runs the exchange. She is the market authority: she creates the market, and she alone can pause it, resume it, and withdraw its fees. Her revenue is a fee of 25 basis points, a quarter of one percent, deducted from each fill's quote proceeds when orders match. In each fill the **maker** is the order that was already resting on the book and the **taker** is the arriving order that crossed it. The side of the fill that receives the quote token bears the fee: the maker when the taker is a bid, the taker when the taker is an ask. Maria's client first creates the empty order book account, then calls `initialize_market()` with her chosen parameters: `fee_basis_points` (25), `tick_size`, `base_lot_size`, `quote_lot_size`, and `min_order_size`. The handler validates all of them: every size must be nonzero, and the fee is capped at 10,000 basis points, as the betting market's is. It creates the market account and the three vaults with the market as authority, records every address on the market account, and stamps the order book's first bytes so each tree knows which side it is. -The tick size and minimum order size keep the book usable, and they do different jobs. Orders below `min_order_size` are rejected with `BelowMinOrderSize`, so every order in the tree holds at least that much of its owner's tokens in a vault, and filling the tree locks up capital. Prices must be a multiple of `tick_size` or the order is rejected, which keeps the book from fragmenting into thousands of dust price levels. The tick also sets what it takes to stand in front of the queue: a maker who wants that place must improve the price by a whole tick, and the taker receives that improvement. +The tick size and minimum order size keep the book usable, and they do different jobs. Orders below `min_order_size` are rejected with `BelowMinOrderSize`, so every order placed is at least that size and pays its owner's tokens into a vault, and filling the tree with new orders ties up capital. Prices must be a multiple of `tick_size` or the order is rejected, which keeps the book from fragmenting into thousands of dust price levels. The tick also sets what it takes to stand in front of the queue: a maker who wants that place must improve the price by a whole tick, and the taker receives that improvement. Both are set per market, at creation, and cannot change afterward. A tick right for a $250 stock is far too coarse for a token priced at a fraction of a cent, so somebody has to choose one for each market, and the choice is final. Manifest removed the tick entirely and lets an order name any price in minor units. Its own README states the tradeoff: without a tick, the newest order can stand in front of the queue by improving the price by a negligible amount, which inverts time priority.[^manifest-tickless] @@ -110,18 +110,18 @@ The handler snapshots the unsettled amounts, **zeroes the counters**, and transf ![](figures/order-book-settle.svg) -## Finished Accounts Close and Return Their Rent - -A Filled or Cancelled order holds nothing the book still needs: its leaf is out of the tree, its ID is out of the owner's open-order list, and any refund is already in their unsettled balance. Its owner calls `close_order()`, which closes the order account and returns its rent to them. An order still Open or PartiallyFilled is refused with `OrderNotClosable`, so it has to be canceled first. Bob closes his Cancelled order and Carol her Filled one (`close_order_returns_a_cancelled_orders_rent` and `close_order_returns_a_filled_orders_rent`). - -With nothing open or owed, each of them can also call `close_market_user()`, which closes their market user account and returns its rent. While the account lists an open order or holds an unsettled balance, the handler refuses with `MarketUserNotClosable`, because a later fill, cancel, or `settle_funds()` would still need it (`close_market_user_refuses_an_open_order` and `close_market_user_refuses_an_unsettled_balance`). Both handlers refuse anyone but the account's owner with `Unauthorized`. Alice's order still rests, so her two accounts stay open. - ## The Market Authority Withdraws the Fees At any time, Maria calls `withdraw_fees()`, and the fee vault's balance (1.875 USDC so far) transfers to her token account. The handler compares the signer against the authority recorded on the market at creation and rejects anyone else with `NotMarketAuthority`. When the fee vault is empty, the handler succeeds as a no-op instead of failing: it is idempotent, so Maria can run it on a schedule unattended. ![](figures/order-book-withdraw-fees.svg) +## Finished Accounts Close and Return Their Rent + +A Filled or Cancelled order holds nothing the book still needs: its leaf is out of the tree, its ID is out of the owner's open-order list, and any refund is already in their unsettled balance. Its owner calls `close_order()`, which closes the order account and returns its rent to them. An order still Open or PartiallyFilled is refused with `OrderNotClosable`, so it has to be canceled first. Bob closes his Cancelled order and Carol her Filled one (`close_order_returns_a_cancelled_orders_rent` and `close_order_returns_a_filled_orders_rent`). + +With nothing open or owed, each of them can also call `close_market_user()`, which closes their market user account and returns its rent. While the account lists an open order or holds an unsettled balance, the handler refuses with `MarketUserNotClosable`, because a later fill, cancel, or `settle_funds()` would still need it (`close_market_user_refuses_an_open_order` and `close_market_user_refuses_an_unsettled_balance`). Both handlers refuse anyone but the account's owner with `Unauthorized`. Alice's order still rests, so her two accounts stay open. + ## The Math: Lots, Ticks, and a Fee That Rounds Up **Lots.** The program never stores "shares" or "dollars"; both sides of the book are denominated in **lots**. The market defines `base_lot_size` and `quote_lot_size`, and the two conversion rules are: raw base = quantity × base lot size, and raw quote = price × quantity × quote lot size. Choosing the lot sizes from the two tokens' decimals (each is ten to the power of the decimal difference, floored at one) makes `price` read as the human quote-per-base rate regardless of how mismatched the mints' decimals are. For NVDAx (8 decimals) over USDC (6 decimals) that gives a base lot of 100 raw NVDAx and a quote lot of 1, and a stored price of 250 reads directly as $250.00 per share. The story writes its quantities in shares and its prices in dollars, and the figures print them the same way; the fields hold lots, so Bob's `original_quantity` of four shares is stored as 4,000,000 lots. OpenBook and Phoenix both use a lot model like this. Manifest instead stores every quantity in minor units and widens the arithmetic, as this program already does for its intermediates. @@ -139,15 +139,15 @@ At any time, Maria calls `withdraw_fees()`, and the fee vault's balance (1.875 U - `filled_quantity` never exceeds `original_quantity`, and order status only moves forward: Open to PartiallyFilled to Filled, or out to Cancelled. - The fee vault only ever receives the computed fee, and only the recorded authority can withdraw from it. - Every leaf in a tree corresponds to exactly one Open or PartiallyFilled order account, and to an entry in its owner's open-order list. -- No order account or market user account closes while the book or an unsettled balance still refers to it. +- No order account closes while its order rests on the book, and no market user account closes while it lists an open order or holds an unsettled balance. ## The Failure Modes: Vault Swaps, Fake Makers, and Book Spam - **The vault swap.** The fee vault and the quote vault hold the same token and have the same authority, so a token-program transfer cannot tell them apart. Without further checks, a caller invoking `settle_funds()` could pass the fee vault where the quote vault belongs and be paid out of Maria's fees. The program stops this with a stored-address check on every instruction, binding every vault and mint to the addresses recorded on the market at creation, and `settle_funds_rejects_fee_vault_substituted_for_quote_vault` checks that the attack fails. This is the same account-substitution attack the *Escrow* chapter described, against a bigger program. - **The fake maker list.** Takers supply the maker accounts they match against, so a hostile taker will supply the wrong ones. The program checks them against its own plan, as the match step showed. -- **The rounding drain.** The fee ceils, as the math section showed, and `fee_rounds_up_when_gross_is_not_a_bps_multiple` checks it. +- **The rounding drain.** A fee rounded down would undercharge a fill by up to one minor unit; the fee rounds up, as the math section showed, and `fee_rounds_up_when_gross_is_not_a_bps_multiple` checks it. - **Book spam.** Each side holds 512 orders, so anyone willing to fund 512 minimum-size orders across 26 or more wallets can fill a side with orders far from the spread. If a full side refused every new order, that trader could keep every new order off the side for as long as they liked. Maria could not move the pair either, because the market PDA's seeds are the two mints. The program stops this with **eviction**. When an order would rest on a full side, `place_order()` compares its price with that side's worst order, the lowest bid or the highest ask. A better price removes the worst order and takes its place, and the evicted owner is refunded through their unsettled balance, as `cancel_order()` refunds them. An order at the same price or worse is rejected with `OrderBookFull`, because the resting order was there first. Eviction only removes the order furthest from the market price, the one least likely to fill, so a spammer's far-off orders cannot keep new orders off the book. The placer passes the worst order and its owner's market user account after the maker pairs, and the program checks them against the book the way it checks maker accounts: a missing account rejects with `MissingEvictedAccounts` and a wrong one with `EvictedAccountMismatch`. A trader evicting their own worst order passes only that order account. Tick alignment, the minimum order size, and the 20-open-orders-per-user cap still apply. -- **A pause that traps tokens.** The flag stops only `place_order()`, as the ask step showed; `paused_market_still_cancels_and_settles_a_resting_order` and `paused_market_still_pays_out_fills_and_withdraws_fees` check the other handlers while it is set. +- **A pause that traps tokens.** The flag stops only `place_order()`, as the ask step showed; `paused_market_still_cancels_and_settles_a_resting_order` and `paused_market_still_pays_out_fills_and_withdraws_fees` check that `cancel_order()`, `settle_funds()` and `withdraw_fees()` still run while it is set. - **The unauthorized paths.** Canceling or closing someone else's order, or closing their market user account, rejects with `Unauthorized`; withdrawing fees, pausing, or resuming without being the authority rejects with `NotMarketAuthority`. ## Testing Considerations @@ -174,7 +174,7 @@ The order book added six concepts on top of the escrow's: - **Every resting order is funded before it rests.** The tokens move into a vault when the order is placed, so a fill never finds them missing. - **A data structure chosen for onchain cost.** The critbit tree inserts without shifting other entries, and walking it visits orders in price-time priority. -- **An internal ledger that consolidates payouts.** Unsettled balances turn many small payouts into one claimed transfer, and zeroing them as they are paid out means each balance is paid once. A trader's account closes only once nothing is open or owed. +- **An internal ledger that consolidates payouts.** Unsettled balances turn many small payouts into one claimed transfer, and zeroing them as they are paid out means each balance is paid once. A trader's market user account closes only once nothing is open or owed. - **A fee whose rounding direction is a security decision.** The fee rounds up, in the market's favor. - **Vault separation plus stored-address checks.** The fee vault and the quote vault stay apart only because every instruction checks their addresses against the market. - **A full book evicts its worst order** for a better one, and refuses an order no better than it, so filling a side with far-off orders keeps nobody off it. diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index af038e89..47c107e6 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -43,7 +43,7 @@ The **pool** account lives at a PDA with seeds `"pool"`, the collateral mint, an - `total_collateral`: traders' collateral held for open positions. It is tracked separately from `liquidity` because it belongs to the traders, not the LPs. - `program_fees`: Maria's accrued fees, separate again. - `insurance_fund`: the share of every fee set aside to pay losses larger than a position's collateral, and profits larger than `liquidity`. -- `long_size`, `short_size`, `long_size_scaled`, `short_size_scaled`: per-side open-interest accumulators, the total size of the open positions on each side. +- `long_size` and `short_size`: the total size of the open positions on each side. `long_size_scaled` and `short_size_scaled`: the same totals with each position's size divided by its entry price, which lets the pool mark every open position at once. - `cumulative_funding` and `last_funding_timestamp`: the funding index. - `average_price`, `last_oracle_price` and `average_price_timestamp`: the pool's own running average of the oracle's price, described below. @@ -51,7 +51,7 @@ The pool is the mint authority of the **LP share mint** (seeds `"lp_mint"` and t ## Liquidity Providers Fund the Pool and Get Shares Priced at Mark -Carol is a liquidity provider. She calls `add_liquidity()` with her whole 1,000 USDC. She is the first depositor, so her shares are her deposit minus `MINIMUM_LIQUIDITY`: a withheld 1,000 minor units, a tenth of a cent, that no one will ever own. That is the first-depositor defense the *Constant-Product AMM* chapter built, with a floor ten times its 100: `add_liquidity()` and `remove_liquidity()` both count the withheld shares in the supply they divide by, so their slice of the pool never leaves. The *Managed Fund* chapter ignores donations; this pool does both, because its own traders can raise `liquidity` without a donation, as the failure modes below show. +Carol is a liquidity provider. She calls `add_liquidity()` with her whole 1,000 USDC. She is the first depositor, so her shares are her deposit minus `MINIMUM_LIQUIDITY`: a withheld 1,000 minor units, a tenth of a cent, that no one will ever own. That is the first-depositor defense the *Constant-Product AMM* chapter built, with a floor ten times its 100: `add_liquidity()` and `remove_liquidity()` both count the withheld shares in the supply they divide by, so their slice of the pool never leaves. The *Managed Fund* chapter defends against donations by ignoring them. This pool does that too, pricing shares from tracked state rather than the vault balance, and withholds the minimum as well, because its own traders can raise `liquidity` without a donation, as the failure modes below show. ![](figures/perps-add-liquidity.svg) @@ -139,7 +139,7 @@ A program runs only when someone sends it a transaction, so the design pays outs ![](figures/perps-liquidation.svg) -If the price had jumped far enough to take Bob's equity below zero before anyone acted, there would be no equity to pay Dave from. The program follows Percolator here: the part of the fee the equity cannot cover is forgiven, and neither the insurance fund nor the LPs pay it. Anyone may still liquidate the position, and the people losing while it stays open have their own reason to: the loss beyond Bob's collateral, called the **deficit**, is paid down the waterfall in the box below. `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` liquidates such a position: the liquidator receives nothing, the position closes, the insurance fund pays 25 of a 40 USDC deficit, and the LPs bear the other 15. +If the price had jumped far enough to take Bob's equity below zero before anyone acted, there would be no equity to pay Dave from. The program follows Percolator here: the part of the fee the equity cannot cover is forgiven, and neither the insurance fund nor the LPs pay it. Anyone may still liquidate the position, and the people losing while it stays open have their own reason to: the loss beyond Bob's collateral, called the **deficit**, is paid from `insurance_fund` first and from the LPs' `liquidity` only after the fund is empty. `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` liquidates such a position: the liquidator receives nothing, the position closes, the insurance fund pays 25 of a 40 USDC deficit, and the LPs bear the other 15. > **Where a loss too big to pay goes.** A leveraged position can lose more than the collateral standing behind it, and somebody still has to pay the winner on the other side. Venues answer with a waterfall, deciding in advance who pays, in this order: > diff --git a/building-financial-products-in-solana/prop-amm.md b/building-financial-products-in-solana/prop-amm.md index 32c01242..c2c681d7 100644 --- a/building-financial-products-in-solana/prop-amm.md +++ b/building-financial-products-in-solana/prop-amm.md @@ -121,8 +121,8 @@ A traditional market-making desk quotes two-sided prices on an exchange, hedges Three concepts came with the prop AMM: -- **Quoting from a price feed.** The price comes from a price feed, not from reserves: no price impact, so no sandwich; no shares, so no inflation attack. When the firm is the only capital in the market, the oracle checks protect its own capital. -- **Adverse selection**, and the four commercial answers to it: spread, ladder, pause, segmented flow. +- **Quoting from a price feed.** The price comes from a price feed, not from reserves, so there is no price impact and nothing to sandwich. When the firm is the only capital in the market, the oracle checks protect its own capital, and with no shares there is no inflation attack. +- **Adverse selection**, the loss a market maker takes to traders who know the price has moved, and the four commercial answers to it: spread, ladder, pause, segmented flow. - **When balances legitimately are the accounting.** With one depositor and no other claimant, the vault balances are the ledger. Traders accept the prop AMM's concentrated trust (one firm, one price feed, one pause switch) because they leave nothing in its custody and see their price before they sign. In the *Lending and Borrowing* chapter we will build a program where users do leave their tokens in the program's custody, as supplied loans and as collateral, and the same kind of oracle values that collateral continuously. You will see what a lender has to do to stay solvent when the collateral falls, and who is paid to do it. diff --git a/conclusion/where-to-go-from-here.md b/conclusion/where-to-go-from-here.md index 664f0738..fb2e8098 100644 --- a/conclusion/where-to-go-from-here.md +++ b/conclusion/where-to-go-from-here.md @@ -19,9 +19,9 @@ An auditor starts with the same questions and goes much further. You can iterate with an AI as a peer: the companion AI plugin applies this book's rules to generated code, and you can now see when that output is wrong before it holds anyone's assets. -You can reason about who earns what, and why anyone takes part. Every program chapter after *Escrow* named the operator's incentive and the user's, because a crank, a liquidation or a settlement only happens if someone earns something by calling it. +You can reason about who earns what, and why anyone takes part. Every program chapter said what its operator and its users each get from it, and said plainly when a program has no operator at all, because a crank, a liquidation or a settlement only happens if someone earns something by calling it. -And you can carry the responsibility that comes with all of it. +You can take responsibility for a program once it holds other people's assets: the *Operating Financial Software* chapter covers testing its edges, waiting for finality, limiting what its admin can do, auditing it with a verified build, and monitoring it after launch. ## The World Still Runs on Business Hours @@ -52,7 +52,7 @@ So aim at consumers and businesses rather than at a crypto audience. In practice **A pot your friends collect when you skip a week.** Five friends stake fifty USDC each at the start of the month, and whoever misses their target forfeits a share to the ones who did not. That is the parimutuel pool of the *Betting Market* chapter: stakes in a vault, one settlement, winners split the pot. Who ran is an offchain fact, signed by someone the program trusts, as the *Offchain Truth* chapter covers. People go for a run when their own fifty USDC is at stake. -**A savings club with no bookkeeper.** Everyone pays a fixed amount in each month, one member takes the whole pot each round, and each member's turn comes once. Hundreds of millions of people run one, under a different name in each country.[^rosca-scope] It is a tanda in Mexico, a susu in West Africa and the Caribbean, a hui in China and Vietnam, and a chit fund in India. Economists call all of them a rotating savings and credit association, or ROSCA. A program removes the organizer, who held the pot between rounds. Build it from contributions paid into a vault, the rotation as a state machine, deadlines against the clock as the *Time* chapter describes, and the *Escrow* chapter's vault with a PDA as its authority. +**A savings club with no bookkeeper.** Everyone pays a fixed amount in each month, one member takes the whole pot each round, and each member's turn comes once. Hundreds of millions of people run one, under a different name in each country. It is a tanda in Mexico, a susu in West Africa and the Caribbean, a hui in China and Vietnam, and a chit fund in India. Economists call all of them a rotating savings and credit association, or ROSCA.[^rosca-scope] A program removes the organizer, who held the pot between rounds. Build it from contributions paid into a vault, the rotation as a state machine, deadlines against the clock as the *Time* chapter describes, and the *Escrow* chapter's vault with a PDA as its authority. **A rental deposit that pays its interest to the tenant.** In most places, a month or two of rent sits for a year in the landlord's account or an agency's, earning for whoever holds it. In a vault neither party controls it can pay the tenant instead, and still be released to either side when the lease ends. The design work is the state where the two sides disagree about damage, and making sure the deposit cannot be stuck there. diff --git a/financial-programming-concepts-in-solana/intro.md b/financial-programming-concepts-in-solana/intro.md index 4a290ce5..4690334a 100644 --- a/financial-programming-concepts-in-solana/intro.md +++ b/financial-programming-concepts-in-solana/intro.md @@ -1,6 +1,6 @@ # Chapter 6: State Machines -Every financial program shares one shape: a state machine. Every account the program creates for a participant (an offer, a receipt, a bet, a position) is eventually closed, returning its rent deposit. +Every financial program shares one shape: a state machine. Every account a program creates to hold one participant's state (an offer, a receipt, a bet, a position) has a handler that closes it and returns its rent deposit to whoever paid it. ## Financial Software Is a State Machine @@ -19,7 +19,7 @@ The state drawing gives the design rules: - **Make the state explicit.** A `status` enum on the account, or, often better, the account's own existence: the betting market's `Bet` account closes when the position ends, so "already claimed" means the account is gone. Existence is better where the state is terminal, because a closed account cannot be claimed twice and holds no rent. A state inferred from other fields is worse than either; the *Betting Market* chapter shows why its event records a Draft status instead of reading one off an empty pool. - **Check state, not just signers.** Betting on a settled event, canceling a filled order, and liquidating a healthy loan must fail because the *state* forbids them, with an error that names the rule. - **Every participant must be able to get their assets out of every state.** If an escrow had no cancel handler, it would strand the maker's tokens if nobody took the offer. If a betting market had no cancel-and-refund path, it would strand every stake if the event was called off. Walk each state and ask: how do each party's assets leave from here? If any state has no answer, the program strands funds, and correct arithmetic will not fix that. -- **On every path, the last handler closes the vault and the state accounts and returns each account's rent to whoever paid it.** +- **Where a path ends, make its last handler close the vault and the state accounts it used, and return each account's rent to whoever paid it.** The escrow's `take_offer()` and `cancel_offer()` both do; a pool that stays open for new users has no last handler. ## Whoever Opens an Account Pays Its Rent, and Gets It Back @@ -30,5 +30,5 @@ Whoever wants the account pays its deposit: the maker funds the offer account an ## What You Learnt - Every financial product is a small state machine: draw it before writing any handler, name every state and transition, and make sure every participant is able to get their assets out of every state. -- Give each transition its own handler, and check the account's state, not only who signed. On every path, the last handler closes the vault and the state accounts. +- Give each transition its own handler, and check the account's state, not only who signed. Where a path ends, make its last handler close the vault and the state accounts. - Whoever opens an account pays its rent deposit and gets it back when the account closes; name the handler that closes every account your program creates. diff --git a/financial-programming-concepts-in-solana/operating-financial-software.md b/financial-programming-concepts-in-solana/operating-financial-software.md index 66334262..6aab894c 100644 --- a/financial-programming-concepts-in-solana/operating-financial-software.md +++ b/financial-programming-concepts-in-solana/operating-financial-software.md @@ -53,9 +53,9 @@ Waiting adds latency, and how much is set by the network's consensus rules, not Most programs in this book have an operator with real power: withdrawing fees, settling events, setting prices, changing weights. Admin power is normal, and on Solana upgrade authority is expected, because a program has to be fixable when a bug turns up. *Unbounded* power, however, is a problem. Write a specification for the admin's private key with three parts: what it can sign for, what it can never do, and what happens when its holder acts against users or stops responding. -- **Bound what configuration can express.** Validate every parameter at write time against a limit that makes abuse impossible by configuration. The managed fund refuses a fee above 10% at creation, as the *Managed Fund* chapter shows. The lending program refuses a loan-to-value ratio above the liquidation threshold, so no loan it grants is liquidatable the moment it is taken. -- **Snapshot terms when participants commit their tokens**, which is the *Offchain Truth* chapter's rule. The managed fund's fee has no setter. Let a parameter the admin can change apply only to deposits that arrive after the change. The lending market's `update_reserve_config()` accrues interest at the old curve, then writes the new one onto every open loan from that second on, which is why its rate curve is bounded at write time. -- **Give the admin no path to user deposits.** No instruction handler in the book's programs, under any parameters, moves user deposits to the operator. The way to make that checkable is to keep the operator's fees in their own place. The order book's fee vault holds only the fee carved out of each fill's quote proceeds, and `withdraw_fees()` moves only from that vault to the market authority. The lending reserve keeps its share in `accumulated_program_fees`, which the exchange rate subtracts before valuing a supplier's shares, so collecting it changes nothing a supplier is owed. A rebalance swaps one asset for another through the router the fund recorded at creation, with a minimum output from the oracle, and the betting market's settlement pays each winner by formula, at the fee fixed when the event was created. When that holds in your program, say it in those words rather than claiming "non-custodial": the deployed program sets the rules, and the operator is not in them. +- **Bound what configuration can express.** Validate every parameter at write time against a limit that makes abuse impossible by configuration. The managed fund refuses a fee above 10% at creation, as the *Managed Fund* chapter shows. The lending program refuses a loan-to-value ratio above the liquidation threshold, so no loan it grants is liquidatable the moment it is taken. Where a parameter must stay adjustable, bound it instead. The lending program caps borrow rates at 300% a year and refuses a liquidation bonus the collateral could not pay at the threshold. An update may raise a reserve's liquidation threshold but never lower it, so no update moves the liquidation line toward an open borrow. +- **Snapshot terms when participants commit their tokens**, which is the *Offchain Truth* chapter's rule. The managed fund's fee has no setter. Let a parameter the admin can change apply only to deposits that arrive after the change. +- **Give the admin no path to user deposits.** No instruction handler in the book's programs, under any parameters, moves user deposits to the operator. The way to make that checkable is to keep the operator's fees in their own place. The order book's fee vault holds only the fee carved out of each fill's quote proceeds, and `withdraw_fees()` moves only from that vault to the market authority. The lending reserve keeps its share in `accumulated_program_fees`, which the exchange rate subtracts before valuing a supplier's shares, so collecting it changes nothing a supplier is owed. A rebalance swaps one asset for another through the router the fund recorded at creation, with a minimum output from the oracle, and the betting market pays each winner by formula through `claim_winnings()`, at the fee fixed when the event was created. When that holds in your program, say it in those words rather than claiming "non-custodial": the deployed program sets the rules, and the operator is not in them. - **Give a pause two levels, and let only the second stop withdrawals.** The first level refuses new deposits and new positions and leaves withdrawals running, so it protects users during an incident without trapping their deposits. The order book's `pause_market()` is a first level: it stops `place_order()` and leaves `cancel_order()`, `settle_funds()`, and `withdraw_fees()` running. The prop AMM's `paused` flag, set through `set_quote()`, does the same for `swap()`. The second level stops withdrawals too, for the incident where the bug is in a withdrawal handler, since stopping that handler is the only way to stop the loss before a fix ships. - **Bound the second level so it cannot become a lockup.** Give it to a stricter authority than the first, such as a multisig that needs more signers, and make it expire on its own after a fixed time written into the program. A halt can then delay withdrawals but never end them, and once it expires every participant can get their assets out of every state again. - **Plan for the admin going silent.** Let nothing a user does wait on the operator: a deposit, a withdrawal, or canceling their own offer runs without an admin signature. The order book's `withdraw_fees()` succeeds as a no-op when the fee vault is empty, as the *Order Book Exchange* chapter says, so a scheduled job can call it unattended. And plan for what happens if the price feed stops publishing. The lending market reads a price only where a debt is at stake: `redeem_reserve_collateral()` takes no price feed, and `withdraw_obligation_collateral()` reads none when the borrower owes nothing (`debt_free_withdraw_needs_no_price_and_no_refresh`). A borrower can repay, which reads no price either, and then take their collateral out (`withdraw_after_full_repay_needs_no_price`). diff --git a/financial-programming-concepts-in-solana/the-adversary.md b/financial-programming-concepts-in-solana/the-adversary.md index c37c2787..1d1f5a5b 100644 --- a/financial-programming-concepts-in-solana/the-adversary.md +++ b/financial-programming-concepts-in-solana/the-adversary.md @@ -14,7 +14,7 @@ Three consequences follow: ## Validation: Signers, Owners, and the Confused Deputy -Cashio and Wormhole, the two War Stories below, both came from a program trusting an account it should have checked. The caller chooses which accounts an instruction runs against, so each account is only a claim until the program checks it against something it already trusts: a signature, an owner, a stored address, or a derivation. +Cashio and Wormhole, the two War Stories in this section, both came from a program trusting an account it should have checked. The caller chooses which accounts an instruction runs against, so each account is only a claim until the program checks it against something it already trusts: a signature, an owner, a stored address, or a derivation. There are four checks: @@ -83,7 +83,7 @@ Fix this by following those rounding rules everywhere, and by writing one kind o ## Ordering and Composability: CPI Trust Boundaries -A transaction can call your program, which can make a **cross-program invocation** (CPI), one program calling another, and that program can call a third. The flash loan in the *Core Building Blocks of Solana* chapter is three instructions in one transaction, and each is a point where code you did not write runs: between your instructions in the same transaction, or inside a CPI your program makes. +A transaction can call your program, which can make a **cross-program invocation** (CPI), one program calling another, and that program can call a third. The flash loan in the *Core Building Blocks of Solana* chapter runs the borrower's instructions between the instruction that lends and the one that repays. Code you did not write can run between your instructions in the same transaction, and inside a CPI your program makes. Code inside a CPI cannot call back into your program. Solana's runtime refuses to invoke a program that is already on the call stack, unless the program is calling itself directly: if your program calls the token program, and a transfer hook on the mint tries to call your program, the transaction fails with `ReentrancyNotAllowed`. A callee upgraded tomorrow or a hook added to a mint next year meets the same refusal. And if any call fails, the whole transaction is undone, including every change your handler made before it. So the order in which a handler updates its own state and calls other programs does not open an attack. Three things at these boundaries do need care: diff --git a/financial-programming-concepts-in-solana/the-arithmetic-of-money.md b/financial-programming-concepts-in-solana/the-arithmetic-of-money.md index a190584c..c210b963 100644 --- a/financial-programming-concepts-in-solana/the-arithmetic-of-money.md +++ b/financial-programming-concepts-in-solana/the-arithmetic-of-money.md @@ -75,7 +75,7 @@ An **invariant** is a condition that must hold before and after every state chan - **The pool product must not decrease.** The AMM's x·y ≥ K check. - **The vaults equal what the books owe.** Vault balances always cover the sum of every recorded obligation. -Check them **after the math, not just before.** The AMM re-checks its product after the transfers complete, and that check catches every bug above it that would overpay a user, including bugs in code that has not been written yet. The check is two reloads and a multiplication; without it, nothing bounds what a bug above it can pay out. +Check them **after the math, not just before.** The AMM re-checks its product after the transfers complete, and that check catches any bug above it that overpays a user by more than the fee the swap leaves in the pool, including bugs in code that has not been written yet. The check is two reloads and a multiplication; without it, nothing bounds what a bug above it can pay out. A degenerate value like zero can make an invariant hold vacuously: with one reserve at zero, the AMM's pre-trade product is zero, and a check that the new product is at least zero passes whatever the swap pays out. So the AMM requires both reserves positive (`EmptyPoolReserve`) before it prices anything; the *Constant-Product AMM* chapter shows the empty-reserve case. diff --git a/solana-building-blocks/accounts-and-instruction-handlers.md b/solana-building-blocks/accounts-and-instruction-handlers.md index 2699b621..a49cdb44 100644 --- a/solana-building-blocks/accounts-and-instruction-handlers.md +++ b/solana-building-blocks/accounts-and-instruction-handlers.md @@ -83,7 +83,7 @@ For financial software, atomicity is a primitive you design with. Inside a trans - A lending market can have an instruction to refresh its price before another instruction that depends on the price, so it does not act on stale state. - A **flash loan**, a crypto-specific concept, borrows in a transaction's first instruction, uses the borrowed tokens in the middle, and repays in the final instruction. The loan needs no collateral because the transaction is atomic: if the repayment fails, the borrow is unwound with it, so the borrowed tokens are back in the lender's vault at the end of every transaction. -Whole categories of arbitrage strategies, and of liquidation (selling a borrower's collateral when their debt outgrows it), exist only because transactions are all-or-nothing. +Whole categories of arbitrage strategies, and of liquidation (repaying part of a borrower's loan once it passes a safety limit, in return for some of their collateral at a bonus), exist only because transactions are all-or-nothing. ![](figures/atomic-transaction.svg) @@ -124,7 +124,7 @@ Besides its address, every account records three things a program reads: a lampo Every account holds a lamport deposit that grows with its size, called **rent**; an account funded to the **rent-exempt** minimum stays until it is closed, and today an account cannot be created with less. Close the account and the lamports are refunded. A tiny part of Alice's SOL balance is the deposit on the space her account occupies; a large account holds a larger one. -The programs themselves are stored the same way. Deploying a program invokes Solana's **loader** program which writes the new program's compiled code into one or more accounts[^loader-version] of its own, which is why deploying a program costs rent in proportion to the size of the binary. Every piece of data the program manages (every market, offer, and balance) lives in its own account. +The programs themselves are stored the same way. Deploying a program invokes Solana's **loader** program which writes the new program's compiled code into one or more accounts[^loader-version] of its own, which is why deploying a program costs rent in proportion to the size of the binary. Every piece of data the program manages (every market, offer, and balance) lives in a separate data account, not in the accounts that hold the program's code. Like the programs on your regular computer and the files they work with, Solana programs use their instruction handlers to read the accounts they are handed, and write new values back. This split, logic in program accounts and state in data accounts, is what makes financial state easy to read. To know a market's condition you read its accounts. diff --git a/solana-building-blocks/figures/clock-decision-tree.svg b/solana-building-blocks/figures/clock-decision-tree.svg index 2885bb04..08e7b943 100644 --- a/solana-building-blocks/figures/clock-decision-tree.svg +++ b/solana-building-blocks/figures/clock-decision-tree.svg @@ -21,7 +21,7 @@ yes whichever unit they stamped - and reject a price stamped before the last restart + reject any price stamped at or before a restart no diff --git a/solana-building-blocks/figures/pda-key-value-store.svg b/solana-building-blocks/figures/pda-key-value-store.svg index 58a960a4..b9dda5b4 100644 --- a/solana-building-blocks/figures/pda-key-value-store.svg +++ b/solana-building-blocks/figures/pda-key-value-store.svg @@ -2,7 +2,7 @@ How programs store records the seeds are the key - different key = different record (illustrative) + different key = different record diff --git a/solana-building-blocks/pdas.md b/solana-building-blocks/pdas.md index f94f609e..ffdd2bb2 100644 --- a/solana-building-blocks/pdas.md +++ b/solana-building-blocks/pdas.md @@ -4,7 +4,7 @@ You have been using PDAs since the *Tokens* chapter: every associated token acco A **program derived address**, or PDA, is an address computed from a program's ID and some chosen seeds, the inputs to the derivation. The derivation produces an address with no private key: PDA generation tries one extra byte after another, called the **bump**, until the result lands off the curve where public keys live. Programs store the bump they found, so later instructions use the stored bump instead of searching for it again. Only the program whose ID derived the address can "sign" as the PDA, and no signature is ever produced; the boxout below says what the word means here. -PDAs are how Solana programs find their data, and the Associated Token Program chose its seeds to suit its task: find where a wallet's balance of a given token lives. Every other program chooses its own seeds for its own needs. A program's accounts behave like a **key/value store**: the seeds are the key, the PDA they derive is the address that key leads to, and the data in the account at that PDA is the value. The PDA is the address and the account is what sits at it: a program derives the offer PDA, then writes the offer account. Every program in this book keeps its state this way: +**PDAs are how Solana programs find their data.** The Associated Token Program chose its seeds to suit its task: find where a wallet's balance of a given token lives. Every other program chooses its own seeds for its own needs. A program's accounts behave like a **key/value store**: the seeds are the key, the PDA they derive is the address that key leads to, and the data in the account at that PDA is the value. The PDA is the address and the account is what sits at it: a program derives the offer PDA, then writes the offer account. Every program in this book keeps its state this way: - The escrow stores each offer at an address made from `"offer"` + the maker's address + an ID. - The fundraiser stores its terms at an address made from `"fundraiser"` + the maker's address, and one receipt per backer at an address made from `"contribution"` + the fundraiser + the backer's address. diff --git a/solana-building-blocks/tokens.md b/solana-building-blocks/tokens.md index 16c76857..684d8ffc 100644 --- a/solana-building-blocks/tokens.md +++ b/solana-building-blocks/tokens.md @@ -1,13 +1,11 @@ # Chapter 3: Tokens -Every asset on Solana except SOL is a **token**, held in a token account (an account formatted in a particular way to hold a token balance) that a token program manages. Every other asset in this book is a token: +Every asset on Solana except SOL is a **token**, held in a token account (an account formatted in a particular way to hold a token balance) that a token program manages.[^tokens-xyz] The tokens in this book include: - stablecoins like USDC and USDT - equities like NVDAx, SPCXx, and TSLAx[^tokenized-equity-volume] - the share tokens the book's own programs mint -So is every other asset on Solana apart from SOL itself.[^tokens-xyz] - ## Every Distinct Token Has Its Own Mint Each kind of token is defined by its own **mint account**. @@ -26,7 +24,7 @@ A mint does not just describe a type of token; it manufactures it. New tokens co As well as the current total supply, the mint account also stores the token's decimals (how many decimal places a major unit has) and its authorities. -The mint authority is the boss of that factory. It can be a person's wallet (they can mint at will: fine for a test token, alarming for a token people rely on), a multisig (minting takes a quorum), or a PDA. This book's programs use a PDA: each lending reserve's share mint and the fund's share mint are run by PDAs the program derives (the reserve and the fund), so new shares are minted only by the program's handlers, never by a person. The authority can also be handed over, or renounced by setting it to none, after which nothing more can be minted. Many tokens explicitly advertise a fixed supply, and disabling the mint authority is how it is enforced. +The mint authority is the boss of that factory. It can be a person's wallet (they can mint at will: fine for a test token, alarming for a token people rely on), a multisig (minting takes a quorum), or a PDA. This book's programs use a PDA: each lending reserve's share mint and the fund's share mint are run by PDAs their programs derive (the reserve PDA and the fund PDA), so new shares are minted only by those programs' handlers, never by a person. The authority can also be handed over, or renounced by setting it to none, after which nothing more can be minted. Many tokens explicitly advertise a fixed supply, and disabling the mint authority is how it is enforced. A token can also stand for one unique thing. Give the mint 0 decimals, mint a single unit, and make sure nobody can mint another: the result is a **non-fungible token**, or NFT, and the one token account holding it records who owns that thing. It could be a ticket to a particular seat, a property deed, or a bond certificate with its own serial number. So each NFT has a mint of its own: a collection of 10,000 NFTs made this way is 10,000 mints, not one mint that issued 10,000 tokens.