diff --git a/AMAZON-DESCRIPTION.md b/AMAZON-DESCRIPTION.md index 2ef89454..22e8e42d 100644 --- a/AMAZON-DESCRIPTION.md +++ b/AMAZON-DESCRIPTION.md @@ -6,11 +6,20 @@ **Building Financial Software on Solana** is a detailed, program-by-program guide to building financial products as Solana programs: how a financial idea becomes accounts, instruction handlers, arithmetic, and checks that hold when someone attacks them. -Written for engineers shipping software that holds other people's assets, and for the analysts, risk officers, and product people who need to understand that software without writing it, it leaves the internals of consensus and validators to other books and covers the layer above: token mints and the authorities that decide who may mint, freeze and transfer, program derived addresses and vault custody, the two clocks and which one an adversary can bend, integer arithmetic in minor units, rounding as a security boundary, oracles and the provenance, freshness, and confidence checks a program runs before acting on a price, conservation invariants, state machines with an exit from every state, cranks, admin power, and the attack classes that have drained real markets. +Written for engineers shipping software that holds other people's assets, and for the analysts, risk officers, and product people who need to understand that software without writing it, it leaves the internals of consensus and validators to other books and covers the layer above: -**Part I** builds the fundamentals: Solana's accounts, tokens, PDAs, and time, then financial software as a state machine, the arithmetic of money, offchain truth, the adversary, and operating a program in production. Exploit classes are taught through the incidents that made them famous, among them the $2.6B SPL token-lending rounding bug, Cashio ($48M), Wormhole ($326M), and Mango Markets ($116M). +- token mints and the authorities that decide who may mint, freeze and transfer +- program derived addresses and vault custody +- the two clocks and which one a block producer can skew +- integer arithmetic in minor units, and rounding as a security boundary +- oracles, and the provenance, freshness, and confidence checks a program runs before acting on a price +- conservation invariants, and state machines with an exit from every state +- cranks and admin power +- the attack classes that have drained real markets -**Part II** walks ten complete, tested programs end to end: an escrow, an all-or-nothing fundraiser, a parimutuel betting market, an order book exchange with a critbit matching engine, a constant-product AMM, an oracle-priced proprietary AMM, a lending and borrowing market with per-second interest and a liquidation engine, a managed fund (what some Solana platforms call a "vault"), a fully collateralized options venue, and a perpetual futures venue with funding, margin, and a haircut that keeps the pool solvent. Every chapter gives the account model, the instruction handlers walked as a story, the math in minor units with its rounding directions, the invariants, the failure modes, the tests that catch each one, and the incentives of everyone involved, operator and user alike. Account maps, state machines, and value flows are drawn in one visual language across all ten, so the programs can be read against each other. They have not been explained side by side in one place before. +**Part I** builds the fundamentals: Solana's accounts, tokens, PDAs, and time, then financial software as a state machine, the arithmetic of money, offchain truth, the adversary, and operating a program in production. Exploit classes are taught through the incidents that made them famous, among them the SPL token-lending rounding bug (about $2.6B at risk, fixed before anyone exploited it), Cashio ($48M), Wormhole ($326M), and Mango Markets ($116M). + +**Part II** builds ten complete, tested programs: an escrow, an all-or-nothing fundraiser, a parimutuel betting market, an order book exchange with a critbit matching engine, a constant-product AMM, an oracle-priced proprietary AMM, a lending and borrowing market with per-second interest and a liquidation engine, a managed fund (what some Solana platforms call a "vault"), a fully collateralized options venue, and a perpetual futures venue with funding, margin, and a haircut that keeps the pool solvent. Every chapter gives the account model, the instruction handlers explained as a story, the math in minor units with its rounding directions, the invariants, the failure modes, the tests that catch each one, and the incentives of everyone involved, operator and user alike. Account maps, state machines, and value flows are drawn in one visual language across all ten, so the programs can be read against each other. They have not been explained side by side in one place before. 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. diff --git a/PRINT-SPECIFICATION.md b/PRINT-SPECIFICATION.md index 8942a0e1..5d07c3d4 100644 --- a/PRINT-SPECIFICATION.md +++ b/PRINT-SPECIFICATION.md @@ -23,13 +23,13 @@ The book was previously built at US Letter, 8.5 x 11in. Letter is a printer-pape | Text block | 5.44 x 7.89 in | | Measure | about 90 characters, 41 lines | -**The margins are O'Reilly's, near enough.** An animal book at 7 x 9.19in carries a text block of about 5.2 x 7.4in: 0.85 to 0.9in inside, 0.95 to 1in outside, 0.8in above the text with the running head at about 0.5in, and 1in below with the folio at about 0.6in. On the slightly larger KDP trim, 1in sides and a 1in bottom give a 5.44 x 7.89in block that is the same share of the page. They also clear KDP's inside-margin requirement, 0.625in for a book of 301 to 500 pages (KDP's table, read 5 October 2026), with room for the binding to eat. +**The margins are O'Reilly's, near enough.** An animal book at 7 x 9.19in carries a text block of about 5.2 x 7.4in: 0.85 to 0.9in inside, 0.95 to 1in outside, 0.8in above the text with the running head at about 0.5in, and 1in below with the folio at about 0.6in. On the slightly larger KDP trim, 1in sides and a 1in bottom give a 5.44 x 7.89in block that is the same share of the page. They also clear KDP's inside-margin requirement, 0.625in for a book of 301 to 500 pages (KDP's table, read October 5, 2026), with room for the binding to eat. The measure that leaves, 5.44in of 10pt type, runs to about 90 characters. That is long by the usual rule of thumb, and it is what O'Reilly's own pages run to at the same type size; the book was 11pt on 0.85in margins until it was measured against one, and at 81 characters a line with 38 lines a page it read as a larger, looser page than the shelf it sits on. Margins are symmetric rather than mirrored, where O'Reilly's outside margin is a tenth of an inch wider than the inside. If the interior is ever sent to a printer that wants a larger inside margin than outside, that is a `@page :left` and `@page :right` pair, and the gutter number above is where it starts. -**Every page is cropped to the trim after printing, because Chromium's sheet is bigger than the one asked for.** A 7.44 x 9.69in page is 535.68 x 697.68pt, and Chromium prints 535.92 x 697.92: it rounds the sheet up to the next 1/300in dot. The extra dot cannot be avoided from the page description: the size comes out the same written in `in`, `pt`, `px`, `mm` or `q`, asked for a hundredth of a point smaller, or passed to `page.pdf()` as an explicit width and height instead of through `preferCSSPageSize`. Chromium anchors the content at the top left, so the slack lands on the right and at the bottom, where on a white interior page it is invisible and on the cover it read as a white band down two edges of the art. `build-pdf.mjs` sets each page's MediaBox and CropBox back to the trim, keeping the top-left corner, which both removes the band and makes the file's page size the one this document states. A build whose sheet comes out *smaller* than the trim fails rather than cropping, because that is a page size nobody asked for. +**Every page is cropped to the trim after printing, because Chromium's sheet is bigger than the one asked for.** A 7.44 x 9.69in page is 535.68 x 697.68pt, and Chromium prints 535.92 x 697.92: one 1/300in dot more than asked for in each direction, even though the trim is already a whole number of dots. The extra dot cannot be avoided from the page description: the size comes out the same written in `in`, `pt`, `px`, `mm` or `q`, asked for a hundredth of a point smaller, or passed to `page.pdf()` as an explicit width and height instead of through `preferCSSPageSize`. Chromium anchors the content at the top left, so the slack lands on the right and at the bottom, where on a white interior page it is invisible and on the cover it read as a white band down two edges of the art. `build-pdf.mjs` sets each page's MediaBox and CropBox back to the trim, keeping the top-left corner, which both removes the band and makes the file's page size the one this document states. A build whose sheet comes out *smaller* than the trim fails rather than cropping, because that is a page size nobody asked for. The cover art is drawn a point past the trim on every side so that rounding in Chromium's paint of the image cannot leave a white hairline inside the crop, and the cover's paged.js sheet is widened to match, because that sheet's clip rounds its right edge down by a fraction of a point and would reinstate the hairline there. Only the width is widened: a taller sheet spills the art past the bottom of the cover and onto the top of the title page, which is a hairline of cover black along three edges of page two rather than a clean page. @@ -46,11 +46,11 @@ The cover art is drawn a point past the trim on every side so that rounding in C | Running heads | Gelasio italic | 8pt | | Folio | Gelasio | 9pt | -**The sizes are O'Reilly's.** Their technical books set Minion Pro at about 10pt on 13pt, with code around 8.5pt, captions and footnotes at 8 to 9pt, running heads at 8pt, and sans-serif headings from the mid-20s for a chapter title down to 12pt for the smallest. Point size alone does not carry across faces: Gelasio's x-height is 0.48 of the em where Minion's is 0.43, so 10pt Gelasio reads like 11pt Minion, a shade larger than an O'Reilly page rather than smaller. The book was 11pt until it was measured against one, when it turned out to read like 12pt Minion, with 38 lines a page against O'Reilly's 42. +**The sizes are O'Reilly's.** Their technical books set Minion Pro at about 10pt on 13pt, with code around 8.5pt, captions and footnotes at 8 to 9pt, running heads at 8pt, and sans-serif headings from the mid-20s for a chapter title down to 12pt for the smallest. Point size alone does not carry across faces: Gelasio's x-height is 0.48 of the em where Minion's is 0.43, so 10pt Gelasio reads like 11pt Minion, a shade larger than an O'Reilly page rather than smaller. At its earlier 11pt, the book read like 12pt Minion. **Every face is pinned, so a build is identical on any machine.** This used not to be true: the body asked for Charter and fell back to Georgia, and the figures asked for Georgia and fell back to whatever generic serif the machine had. Two machines produced two different books, and `check-figure-geometry.mjs` measured clearances against a face the printed book would never use, which is how a coin mark sitting on top of a label passed the check. -Gelasio, DejaVu Sans Mono and Denim INK are bundled in `assets/fonts/`. Gelasio is [OFL](https://openfontlicense.org/) and DejaVu Sans Mono is under the permissive DejaVu Fonts License; both licences sit beside the fonts. Denim INK is licensed to Quicknode. +Gelasio, DejaVu Sans Mono and Denim INK are bundled in `assets/fonts/`. Gelasio is [OFL](https://openfontlicense.org/) and DejaVu Sans Mono is under the permissive DejaVu Fonts License; both licenses sit beside the fonts. Denim INK is licensed to Quicknode. The figures were drawn against Georgia and moved to Denim INK, the heading face, for legibility at figure sizes; the sans is a shade wider, and the geometry check confirmed every label still clears its box. Seed lists and built-in addresses, at seven units the smallest text in the book, are DejaVu Sans Mono, a monospace whose letters stay apart where a serif's small capitals blurred. @@ -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: nine before chapters and part titles in the October 6, 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: eleven before chapters and part titles in the October 7, 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 307 interior pages on white (the digital edition's 309 less the cover and its blank reverse) the target is 15.821 x 9.94in with a 0.691in 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 313 interior pages on white (the digital edition's 315 less the cover and its blank reverse) the target is 15.835 x 9.94in with a 0.705in 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`, `--paper white|cream` and `--out ` as options; the output defaults to `build/cover-kdp-pp-.pdf`), to that exact size, filling the target and cropping overflow symmetrically into the bleed. Page count is read from the paperback interior, the file actually uploaded, unless `--pages` is passed. `npm run build-pdf-paperback` runs it straight after building that interior, so the two always agree; run it alone to pass `--paper cream` or `--pages`. 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.821 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`, `--paper white|cream` and `--out ` as options; the output defaults to `build/cover-kdp-pp-.pdf`), to that exact size, filling the target and cropping overflow symmetrically into the bleed. Page count is read from the paperback interior, the file actually uploaded, unless `--pages` is passed. `npm run build-pdf-paperback` runs it straight after building that interior, so the two always agree; run it alone to pass `--paper cream` or `--pages`. 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.835 x 9.94in is a 1140 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 a build at the trim. 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: 273 pages against 315, because the larger page carries more. ## Building It @@ -124,4 +124,4 @@ Each build writes its own file in `build/`: the digital edition is `Building Fin The build and the preview need a Chromium binary, found at `CHROMIUM_PATH` if set, otherwise at `/opt/pw-browsers/chromium`, otherwise at `/opt/homebrew/bin/chromium` (`brew install chromium` on a Mac). -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 **315 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/TABLE-OF-CONTENTS.md b/TABLE-OF-CONTENTS.md index 7183875e..f0bb8f89 100644 --- a/TABLE-OF-CONTENTS.md +++ b/TABLE-OF-CONTENTS.md @@ -20,8 +20,8 @@ - [Chapter 6: State Machines](financial-programming-concepts-in-solana/intro.md) - financial software is a state machine - [Chapter 7: The Arithmetic of Money](financial-programming-concepts-in-solana/the-arithmetic-of-money.md) - integers, scale, rounding, and interest - [Chapter 8: Offchain Truth](financial-programming-concepts-in-solana/offchain-truth.md) - how real-world facts (prices, event outcomes, anything the chain cannot see) get onchain, and how a program verifies them -- [Chapter 9: The Adversary](financial-programming-concepts-in-solana/the-adversary.md) - how onchain money gets stolen, exploit class by exploit class -- [Chapter 10: Operating Financial Software](financial-programming-concepts-in-solana/operating-financial-software.md) - testing money, wielding admin power safely, and monitoring a live program +- [Chapter 9: The Adversary](financial-programming-concepts-in-solana/the-adversary.md) - how attackers take assets from onchain programs, exploit class by exploit class +- [Chapter 10: Operating Financial Software](financial-programming-concepts-in-solana/operating-financial-software.md) - testing, model checking, waiting for finality, verified builds, limiting admin power, and monitoring a live program ## Part II: Building Financial Products in Solana diff --git a/WRITING-PLAN.md b/WRITING-PLAN.md index 2d52dc32..3bb6b7ba 100644 --- a/WRITING-PLAN.md +++ b/WRITING-PLAN.md @@ -5,11 +5,11 @@ How the manuscript gets produced, verified, reviewed, and kept alive. The table ## Principles - The book prints the durable layer. Every code claim is verified against the companion repository before it lands: the skill's "grep before naming" rule, applied to a manuscript. -- Diagrams are the longest-lived medium. Every chapter budget includes them: a state machine, and a figure for every walkthrough step. The step figures share one column layout and accumulate into the program's full account map, so no map is drawn up front. +- Diagrams are the longest-lived medium. Every chapter budget includes a figure for every walkthrough step, and the *Escrow*, *Betting Market*, *Order Book Exchange* and *Options* chapters also draw their program's state machine. The step figures share one column layout and accumulate into the program's full account map, so no map is drawn up front. - Style is inherited wholesale from the [companion AI plugin](https://github.com/quicknode/solana-finance-claude-plugin): onchain and offchain unhyphenated, "instruction handlers" for the functions and "instructions" for their input, "minor unit" not "base unit", "network" for Solana and "program" for onchain code with "protocol" never used for either, one name per role enforced within each chapter, concrete two-asset examples, real handler names in lifecycle prose, persona-driven walkthroughs. Also from the skill, and deliberately not repeated below: handler naming (`initialize_x()` for setup, the domain's verb otherwise), PDA versus account, mint versus token, example assets, plain language and concrete definitions, emphasis, stating the mechanism rather than gesturing at it, and talking to readers rather than about the document. **A rule that would apply to any Solana finance writing belongs in the skill; this file adds only what is specific to the book.** `scripts/lint-manuscript.mjs` still enforces several of the skill's rules over the manuscript, so a rule can live there and be checked here. - Banned phrases, enforced by `scripts/lint-manuscript.mjs` (`npm run lint`) over the manuscript files: "the oracle problem", "instrument" (say "financial software" or "program"), the hyphenated chain words (except in the glossary's onchain entry, which names the spelling it replaces), and em-dashes. The lint also bans a group of phrases that read as machine-written, on the same grounds as the em-dash: a reader clocks them before finishing the sentence, and every one of them stands in for a mechanism rather than stating it. "Load-bearing" is the type specimen; say what depends on the thing and what breaks without it. The rest are "delve", "tapestry", "intricate", "multifaceted", "holistic", "myriad", "seamless", "robust", "pivotal", "crucial", "elevate", "testament to", "deep dive", "paradigm shift", "game-changer", "unlock the power", "realm of", "landscape of", "in today's", "it is worth noting", and "underscores the". A second group is banned for announcing that a point exists instead of making it, each one written in this manuscript and each replaced by the content it stood in front of: "what matters", "the point is", "that is the whole reason", and "does the work of". "What matters here is the unit" became "the index advances by slots, not seconds"; delete the announcement and keep what followed it. The same announcement came back wearing a pronoun once the plain forms were banned, so "that is the point", "which is exactly the point", "is the one that matters", "matters more than it looks" and "it is worth being" are banned too. A related group points the reader at the page instead of telling them what is on it: "Note what is pledged", "Notice who did nothing", "Watch K in the story below". Each is one word longer than the sentence that says the thing, and the fix is to say it. Watching a real market or looking away from the chain is the reader doing something in the world, and stays. "Treats" meaning "discusses" ("the *Lending and Borrowing* chapter treats it fully") is banned; say "explains" or "covers". "Treats X as Y" is a different verb and stays. "Pin" is banned in both of its uses here: a test enforces, checks, or asserts, and a value is fixed or recorded at creation. "Capstone" is banned for rating a chapter's place instead of saying what it does; say "this chapter" or name it. "Walks" with a chapter, book, or suite as its subject is banned as tour-guide framing: a chapter explains or builds, and a suite follows or runs a path; a loop that walks a tree or the states of a machine is mechanism and stays. "Works" meaning succeeds or functions stays ("the deadline arithmetic flips which handler works"); what is banned is working an example ("the chapter works an example"), where the chapter shows or builds it instead. A third group rates the writing instead of doing it, and each was found many times over in one audit: "honest" and "honestly" (twenty uses, none about honesty), "quietly" as an intensifier, "the discipline" and "hygiene" as names for a rule, "by now you", and "worth stealing", "worth copying", "worth noticing", "worth printing", "worth studying". The paragraph-closing maxim ("A wall that has never been pushed on is a hypothesis") is the same tell with no regex to catch it, so the companion AI plugin describes it and the lint does not. A fourth group is mannered prose: an idiom, a slang word, or a metaphor standing where a literal phrase of the same length was available, and each was found in one audit: "a single whale" (a single large backer), "config foot-guns" (dangerous configurations), "zombie positions", "cannot be rugged", "by fat-finger", "after the whistle", "mid-flight", "the hot path", "hand-rolled", "comes off the top", "pays for itself", "comes home", "the ladder has three rungs". A metaphor drags in connotations the writer did not choose, so the fix is to say what you mean. Money words used figuratively are the same failure with a worse result in this book, because a reader takes "buys", "costs", "pays", "earns", "cheap", "price", "trade" and "bet" at their word: "a slot count would buy nothing here and cost a guess" reads as a claim about money, and it was "counting slots would have no advantage here, and would be inaccurate"; "so this costs nothing, and no second is ever charged twice" was "so no second is ever charged twice"; "conviction pays three to one" was "winnings are proportional to stake". Literal costs, prices and payments stay, and so does compute that a fee is charged for. The teaching analogy that carries a mechanism stays (a mint is a factory, Solana is one giant computer), and so does the finance term the field uses (a deep pool, seeding a pool, the rails); the lint catches the words above, and the companion AI plugin describes the rule in general. "Fail open" and "fail closed" are banned for a different reason: an open circuit stops and an open door lets everything through, so readers split on which way each one points. Say what the program does instead: it refuses the transaction, or it lets every request through. The lint also rejects negative framing of offchain truth (any "cannot verify the price" phrasing): the manuscript teaches the positive mechanism, checking a fact's provenance, freshness, and confidence against a source the program trusts. "Property test" is banned in favor of "invariant test", the companion AI plugin's rule, because "property" also names an account's fields; the one exception is the testing chapter's single pointer to the libraries' own term, "property-based testing". "Smart contract" is banned in favour of "program", with exactly three exceptions, all contrastive: the Introduction's line telling a reader arriving from another chain what a Solana program is, the glossary's definition, and the Options chapter's boxout separating an option contract from the program that holds it. - Say what a feed carries. Every feed in this book is a price feed, so the prose says "price feed", never a bare "feed": "three of the four count slots because their feeds stamp each update" made the reader stop to work out what kind of feed it meant. A price feed network is "a price feed network" too. Code identifiers keep their own names (`mock-price-feed`, `InvalidPriceFeed`). `scripts/lint-manuscript.mjs` rejects a bare "feed". -- A key pair is two keys, and only its public key is an address: prose never calls a key pair an address or an account, never puts one on or off the curve, and never says a PDA has one. In the figures, an address made from a key pair is an unlabeled `r="6"` dot on the curve with its account's box beside it; it is never an offcurve dot with a caption, and no caption names a key pair. `scripts/lint-manuscript.mjs` fails the prose and the captions, and `scripts/check-figure-geometry.mjs` fails a public-key dot off the curve, a derived-address dot on it, and any dot more than 20 units from an account box. Seven order-book figures once drew the order book and its vaults as offcurve dots captioned `A CLIENT KEYPAIR`. The vaults are now PDAs of the market; the book, which the client must create, has its box above the curve at the top right, beside its dot. +- A key pair is two keys, and only its public key is an address: prose never calls a key pair an address or an account, never puts one on or off the curve, and never says a PDA has one. In the figures, an address made from a key pair is an unlabeled `r="6"` dot on the curve with its account's box beside it; it is never an offcurve dot with a caption, and no caption names a key pair. `scripts/lint-manuscript.mjs` fails the prose and the captions, and `scripts/check-figure-geometry.mjs` fails a public-key dot off the curve, a derived-address dot on it, and any dot more than 20 units from an account box. In the order-book figures the vaults are PDAs of the market; the book, which the client must create, has its box above the curve at the top right, beside its dot. - No sentence runs past forty-five words, enforced by `scripts/lint-manuscript.mjs` over prose, list items, and boxouts. The ceiling is deliberately loose, because it is catching a specific failure rather than policing rhythm: every sentence it first caught was a list that had been glued into a paragraph, or two separate claims joined by a colon, and the fix each time was to split at the punctuation already sitting there. The same pass bans a short list of words that have a plain equivalent and carry no meaning of their own here: "utilize", "commence", "prior to", "subsequent to", "in order to", "in the event that", "due to the fact that", "whilst", "amongst", "thus", "hence", "therefore", "approximately", "numerous", and "sufficient". Latin phrases are banned on the same grounds, because each has an English equivalent a reader does not have to translate: "pro rata" (say "in proportion to" or "based on the size of"), "per se", "de facto", "ad hoc", "vice versa", "a priori", "inter alia", "ceteris paribus", "per annum", and the abbreviations "e.g.", "i.e.", "etc.", "viz." and "cf.". Words that only *look* bureaucratic stay, because they are exact in this domain: a transaction **executes**, a supplier earns **yield**, a maker **provides** liquidity, a program signs by **presenting** seeds, and a firm **operates** a venue. "Confirm" is banned as a loose synonym for "check", because `confirmed` is a Solana commitment level and the book teaches the two that remain under Alpenglow. This is the one rule taken from ASD-STE100 Simplified Technical English; the rest of that standard is written for aircraft maintenance procedures, and its bans on direct address, rhetorical questions, and metaphor would flatten a book that teaches with a story. - An address is written as a markdown link, `[host.tld](https://host.tld)`, never as loose prose, because the PDF build only reaches links. It prints in italic, which is what technical publishing does with an address (O'Reilly's house style and No Starch's) and needs no machine face to tell a reader it is copied exactly; the monospace face stays with `program_id`, where it separates code from the prose around it. Never underlined: underlining is a screen convention and collides with descenders in a serif face. The scheme prints, because it is part of the address and because technical publishing prints one: O'Reilly sets a link as "words (https://host/path)". A leading `www.` does not, being a subdomain nobody types and every browser supplies. The link keeps whatever it was written with, and the link is what a click follows, so write the link text as the host and path alone and let the markdown target carry the full address. A link whose text is words rather than an address gets that address printed after it in parentheses, since nobody can click a printed page. Both are done by `scripts/build-pdf.mjs`; the lint catches an address written as prose. A long address may break across lines, at a slash; what may not break is where it points, so the PDF build reads the link annotations back out of the finished PDF and fails unless every line of a broken address carries the whole destination. But an address does not belong in running prose at all: it is a string of punctuation inside a justified measure, and a reader holding the book cannot act on it while reading the sentence anyway. It goes in a footnote, which names the page it points at, so the sentence keeps its words and the address waits at the foot of the page for a reader who wants it. Two things stay inline. `solanabook.org` is short and is the thing its sentence is naming, and the Glossary is reference matter already, whose entries would read oddly with notes hanging off them. - American spelling throughout, which the lint checks in two batches: the "-our" and "-ise" endings, and the doubled-l forms and noun/verb splits British English keeps ("canceling", "modeling", "cancelable", "center", "license"). Identifiers keep whatever the companion repository shipped, so the `Cancelled` enum variant is written capitalized and left alone. Typography in the source is one apostrophe and one set of quotes, both straight, and one italic marker, the asterisk. The figures are typed the same way. The build sets the quotes the way a typeset book carries them, ’ and “ ” in prose, headings, and figure labels, and straight inside code, which is O'Reilly's convention and every other technical publisher's; the lint rejects a curly quote, as a character or an entity, in a chapter or a figure, so that grep and the build only ever meet one form. A single quote is always an apostrophe, never a quotation inside a quotation, and the lint holds the manuscript to that too. Underscore italics are not a second house style: an underscore is a word character, so `\b` never fires against the start of `_Arithmetic of Money_`, and a chapter reference written that way reads clean to the cross-reference check. The lint bans them for that reason. @@ -67,7 +67,7 @@ Each incident is researched from primary sources and placed inside the chapter n **The conventions themselves live in the companion AI plugin's [DIAGRAMS.md](https://github.com/quicknode/solana-finance-claude-plugin/blob/main/skills/solana/DIAGRAMS.md)**, which is the single source of truth for the cluster curve and its canonical path, dot placement, column layout, rounded corners, icons, the `0.3` fade, the one accent green, coin marks, smooth arrows, and where seeds are drawn. `scripts/lint-manuscript.mjs` and `scripts/check-figure-geometry.mjs` enforce the mechanically checkable ones over this repository's figures, so those two scripts and DIAGRAMS.md must be changed together; do not restate a convention here. -What is specific to the book: the conventions are fixed during the single visual pass that draws every figure, and each is taught to the reader by the first figure that uses it, with a small legend on that figure. Every chapter's budget includes a state machine and a figure per walkthrough step. The step figures share one column layout, which is the chapter's account map: it exists as that layout, not as a figure of its own. And: +What is specific to the book: the conventions are fixed during the single visual pass that draws every figure, and each is taught to the reader by the first figure that uses it, with a small legend on that figure. Every chapter's budget includes a figure per walkthrough step. The step figures share one column layout, which is the chapter's account map: it exists as that layout, not as a figure of its own. And: - **Every walkthrough step ("Alice does X", "Bob does Y") includes a diagram** showing the token movements, account creations and closures, and value changes that step caused. The diagram goes where the reader needs it, not at the step's end by habit: before the mechanics when the picture is what the mechanics explain, after them when it is their result. @@ -79,13 +79,13 @@ What is specific to the book: the conventions are fixed during the single visual ## Milestones -- **Voice samples first** (drafted): the Introduction, Escrow, and The Order Book Exchange lock voice, depth, and template. -- The remaining program chapters, one per example, in table-of-contents order (all ten drafted: Escrow, The Fundraiser, The Betting Market, The Order Book Exchange, The Constant-Product AMM, The Prop AMM, Lending and Borrowing, The Managed Fund, Options, Perpetual Futures). They run as three groups: custody and pooling, then market making, then credit and derivatives. A chapter's place is set by what it assumes the reader already has, so The Betting Market sits third, where its only prerequisite is Escrow, and Options sits just before Perpetual Futures, because a fully collateralized derivative is the case the leveraged one relaxes. -- The two Fundamentals sections (Solana Building Blocks; Financial Programming Concepts in Solana), with technical reviewers engaged per chapter as each completes. -- The remaining appendices. The Glossary is drafted. -- The placeholder front-matter pages last. -- A single visual pass producing all diagrams in one consistent style. -- Public early-access release and errata page live before print. +- **Voice samples first** (done): the Introduction, Escrow, and The Order Book Exchange lock voice, depth, and template. +- The remaining program chapters, one per example, in table-of-contents order (done, all ten drafted: Escrow, The Fundraiser, The Betting Market, The Order Book Exchange, The Constant-Product AMM, The Prop AMM, Lending and Borrowing, The Managed Fund, Options, Perpetual Futures). They run as three groups: custody and pooling, then market making, then credit and derivatives. A chapter's place is set by what it assumes the reader already has, so The Betting Market sits third, where its only prerequisite is Escrow, and Options sits just before Perpetual Futures, because a fully collateralized derivative is the case the leveraged one relaxes. +- The two Fundamentals sections (Solana Building Blocks; Financial Programming Concepts in Solana) (drafted), with technical reviewers engaged per chapter as each completes. +- The Glossary, the book's one appendix (drafted). +- The front-matter pages (drafted). +- A single visual pass producing all diagrams in one consistent style (done: every figure is drawn). +- Still to do: public early-access release and errata page live before print. ## Future Chapters @@ -93,4 +93,3 @@ Added if and when a matching example ships in the companion repository, keeping - Staking and Rewards (reward-per-token accounting currently appears inside Lending and Borrowing, and The Betting Market) - Vesting and streaming payments -- Model checking (Kani), as its own section placed after the testing material, once every companion example carries harnesses (perpetual futures does not yet) diff --git a/appendices/glossary.md b/appendices/glossary.md index b9175db3..a6d0b9fe 100644 --- a/appendices/glossary.md +++ b/appendices/glossary.md @@ -34,7 +34,11 @@ Solana's consensus mechanism, which replaced Tower BFT. Under it the [commitment ### American option / European option -An American option may be exercised at any time up to its expiry; a European option only at expiry. The *Options* chapter builds the American kind. +An American option may be exercised at any time before its expiry; a European option only at expiry. The *Options* chapter builds the American kind. + +### AMM (automated market maker) + +A venue where a trade fills at once against tokens the venue holds, at a price a formula or an oracle sets, instead of against resting orders. The *Constant-Product AMM* chapter builds the kind that prices from a formula over its two [reserves](#reserve), and the *Prop AMM* chapter the kind that prices from an oracle; see [constant-product AMM](#constant-product-amm) and [proprietary AMM](#proprietary-amm-prop-amm). ### Anchor @@ -114,7 +118,7 @@ A [put](#put) whose writer has supplied enough of the quote token, USDC here, to ### ceil (ceiling) -Rounding up to the next whole [minor unit](#minor-unit). The direction used for anything a user owes, a debt or a fee, so it never rounds down into a shortfall the program absorbs: every fee a user pays in this book's programs ceils, and the user's side takes what is left. A split of a fee already collected may floor, as the perpetual futures venue's insurance-fund cut does, since either direction keeps the whole fee with the program. The opposite of [floor](#floor). See [rounding](#rounding). +Rounding up to the next whole [minor unit](#minor-unit). The lending market ceils a borrower's debt, and every fee a user pays in this book's programs ceils, so neither rounds down into a shortfall the program absorbs. A split of a fee already collected may floor, as the perpetual futures venue's insurance-fund cut does, since either direction keeps the whole fee with the program. The opposite of [floor](#floor). See [rounding](#rounding). ### checked arithmetic @@ -160,6 +164,10 @@ An oracle's own statement of its uncertainty: a price of $250 ± $8 is a differe A program with authority that an attacker tricks into using it for someone else, usually by passing a different account from the one the program expects. Checking each account's signer, owner, [stored address](#stored-address-check), and PDA derivation stops it. See the *Adversary* chapter. +### constant-product AMM + +An [AMM](#amm-automated-market-maker) that prices each trade so the product of its two [reserves](#reserve), [K](#k), holds steady while the trade is priced: output = taxed_input × other_reserve ÷ (this_reserve + taxed_input). See the *Constant-Product AMM* chapter. + ### counterparty The party on the other side of your trade, and the one who owes you when you win. On an [order book](#order-book) the counterparty is another trader; in this book's pooled venues the program's pool is the counterparty to everyone at once. See the *Perpetual Futures* chapter. @@ -178,7 +186,7 @@ One program calling another onchain, such as any program calling the token progr ### crank -A permissionless instruction handler that performs maintenance work nobody owns (accruing a lending reserve's interest, refreshing a borrower's collateral value), designed so anyone, typically a bot on a schedule, can turn the handle. +A permissionless instruction handler that performs maintenance work nobody owns (accruing a lending reserve's interest, refreshing a borrower's collateral value), designed so anyone can turn the handle. The caller can be each client, running it in its own transaction before its own instruction as the lending market's clients do, or a bot on a schedule. See the *Lending and Borrowing* chapter. ### critbit tree @@ -200,10 +208,18 @@ Holding assets that belong to someone else. A program takes custody when tokens The number of decimal places in one major unit of a token, declared on the mint: USDC has 6, so a dollar is 1,000,000 [minor units](#minor-unit); SOL has 9. +### deficit + +On a perpetual futures venue, the loss beyond a liquidated position's collateral. The *Perpetual Futures* chapter's program pays it from the insurance fund first, and from the LPs' liquidity only after the fund is empty. + ### degenerate value 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). +### delegate + +An address a token account's owner has approved to move that account's tokens, up to the amount approved. See [permanent delegate](#permanent-delegate) and the *Tokens* 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. @@ -218,7 +234,7 @@ Sub-unit remainders left over by integer division: a fraction smaller than one [ ### epoch -A period of 432,000 slots: exactly a day at a 200 millisecond slot, and a little longer in practice because real slots run long. The schedule of [leaders](#leader) is drawn for each epoch, and some changes to the network, a new slot length among them, take effect only when a new epoch begins. +A period of 432,000 slots, a day at the 200-millisecond target. The schedule of [leaders](#leader) is drawn for each epoch, and some changes to the network, a new slot length among them, take effect only when a new epoch begins. ### equity (position) @@ -242,7 +258,7 @@ The moment an [option](#option)'s right lapses: the holder may exercise before i ### exponentiation by squaring -Computing (1 + r)ⁿ, a per-second rate r compounded over n seconds, in log₂(n) multiplications instead of n of them, each one checked and floored the same way, so a rate can be compounded over any number of elapsed seconds in a single update. See [Newton's method (integer square root)](#newtons-method-integer-square-root) for roots, and the *Arithmetic of Money* chapter. +Computing (1 + r)ⁿ, a per-second rate r compounded over n seconds, in log₂(n) multiplications instead of n of them, so a rate can be compounded over any number of elapsed seconds in a single update. Each multiplication is checked and rounded up, so the result is never below the exact power. See [Newton's method (integer square root)](#newtons-method-integer-square-root) for roots, and the *Arithmetic of Money* chapter. ### faucet @@ -292,7 +308,7 @@ Rounding to the nearest whole unit with exact halves going up, which is what Rus A stop in block production. A halt comes from a fault such as a bug in the validator software, and ends when the validators restart the chain together. Slots do not advance during a halt and wall-clock time does, so a halt pulls the two clocks apart: the *Time* chapter covers what that does to accrual, freshness windows, and deadlines, and how the `LastRestartSlot` sysvar lets a program detect that one happened. -### health / health factor +### health A borrower's distance from liquidation: the value of their collateral, scaled by each asset's liquidation threshold, against the value of their debt. Past the boundary, the position may be liquidated. See the *Lending and Borrowing* chapter. @@ -308,9 +324,13 @@ A program's interface definition: the file Anchor generates listing every [instr The gap between an LP position's value and what holding the tokens would be worth, caused by the pool mechanically selling the rising token. Realized only at withdrawal. See the *Constant-Product AMM* chapter. +### in the money / out of the money + +An option worth exercising is **in the money**: a call whose underlying trades above the [strike](#strike), or a put whose underlying trades below it. One that is not is **out of the money**. See the *Options* chapter. + ### index -The lending field's word for a stored number that grows over time and that every claim on a pool is a multiple of. Aave's borrow index, Compound's borrow index, and Save's cumulative borrow rate are all one. This book calls the lending market's index the [borrow accumulation factor](#borrow-accumulation-factor), and the perpetual futures venue's `cumulative_funding` is another: a position's funding owed is its size times the change in that index since it opened. +The lending field's word for a stored number that accumulates over time and that every claim on a pool is a multiple of. Save's cumulative borrow rate is one. This book calls the lending market's index the [borrow accumulation factor](#borrow-accumulation-factor), and the perpetual futures venue's `cumulative_funding` is another. That one rises while longs are heavier and falls while shorts are, and a position's funding owed is its size times the change in that index since it opened. ### inflation attack @@ -338,12 +358,16 @@ Capital a venue sets aside, usually from a share of its fees, to absorb losses l ### invariant -A condition that must hold before and after every state change: a pool's [K](#k) must not decrease across a swap, the vault equals what the book owes, total in equals total out. An **invariant test** asserts one after every state change, at whatever values the test chooses, and a model checker checks one against every possible value. See the *Operating Financial Software* chapter. +A condition that must hold before and after every state change: a pool's [K](#k) must not decrease across a swap, the vault covers what the book owes, total in equals total out. An **invariant test** asserts one after every state change, at whatever values the test chooses, and a model checker checks one against every possible value. See the *Arithmetic of Money* chapter, and the *Operating Financial Software* chapter for invariant tests. ### inventory The tokens a [market maker](#market-maker) holds and quotes against. A prop AMM prices from an oracle and fills out of inventory rather than off a curve. See the *Prop AMM* chapter. +### junior + +See [senior / junior](#senior--junior). + ### K The product of an AMM pool's two reserves. Constant while a trade is priced, growing with deposits and fees, shrinking with withdrawals. @@ -378,7 +402,7 @@ 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 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. +Forcibly reducing or closing a loan or position whose collateral no longer covers it safely, with whoever does it paid for the work. In the *Lending and Borrowing* chapter the liquidator repays part of an unhealthy loan and takes some of its collateral at a [bonus](#liquidation-bonus), and the [close factor](#close-factor) caps how much one call repays. In the *Perpetual Futures* chapter the liquidator closes the whole position once its equity is at or below the [maintenance margin](#maintenance-margin), and is paid a fee from that equity. ### liquidation bonus @@ -390,7 +414,11 @@ The debt-to-collateral ratio at which a position becomes liquidatable. Never bel ### liquidity provider (LP) -Someone who deposits assets into a pool for traders to trade against, earning fees in exchange. +Someone who deposits assets into a pool for traders to trade against, earning the pool's fees on an AMM, or the traders' net losses and funding on the perpetual futures venue. + +### loader + +The Solana program that deploying a program invokes: it writes the new program's compiled code into accounts of its own. See the *Core Building Blocks of Solana* chapter. ### loan-to-value (LTV) @@ -398,7 +426,7 @@ The fraction of collateral value that may be borrowed against it, for example 75 ### long / short -The two directions a position can face: a long profits when the price rises, a short when it falls. On a perpetual futures venue the side is part of the position's identity: the heavier side pays the [funding rate](#funding-rate) and the lighter side receives it. +The two directions a position can face: a long profits when the price rises, a short when it falls. On a perpetual futures venue the side is one of the position account's PDA seeds, so a trader holds at most one long and one short per market, and the heavier side pays the [funding rate](#funding-rate) and the lighter side receives it. ### lot @@ -426,12 +454,16 @@ The onchain equivalent of a mutual fund: pooled deposits, a mandate fixing which ### manager -In a fund, the authority that sets the allocation and earns the management fee, and that can do nothing else: no instruction handler gives them a path to the assets. See [curator](#curator) and the *Managed Fund* chapter. +In a fund, the authority that sets the allocation and earns the management fee. No instruction handler pays a vault's tokens to the manager, except what their fee shares redeem through `withdraw()`, the same as any holder's. See [curator](#curator) and the *Managed Fund* chapter. ### mantissa The digits of a price stated separately from its exponent: a price feed holding mantissa 25,000,000 at exponent −5 means $250. The *Lending and Borrowing* chapter's price feed account stores one as `price_mantissa`. +### margin + +Collateral posted against an obligation or a leveraged position. Traditional options venues let a writer post only a fraction of the obligation as margin; the *Options* chapter's writers post all of it. For the perpetual futures sense, see [initial margin](#initial-margin) and [maintenance margin](#maintenance-margin). + ### mark to market Valuing a position or pool at current prices rather than at the prices it was entered at. @@ -462,7 +494,7 @@ Handing a function to a model checker like Kani, which tries every possible valu ### multisig -An account that records a set of owners and how many must agree before it acts, used wherever one person holding a private key is too much trust: a mint authority, a market's fee authority, a program's upgrade authority. +A program that records a set of owners and how many of them must approve, and signs only once enough have, used wherever one person holding a private key is too much trust: a mint authority, a market authority, a program's upgrade authority. ### NAV (net asset value) @@ -490,7 +522,7 @@ Outside the blockchain: a bank balance, a match result, a price on an exchange. ### onchain -Stored or executed on the blockchain itself: an account's data, a program's code, a transaction's effects. The opposite is [offchain](#offchain). Spelled as one word, like "online"; "on‑chain" will look as dated as "on‑line" does now. +Stored or executed on the blockchain itself: an account's data, a program's code, a transaction's effects. The opposite is [offchain](#offchain). Spelled as one word, like "online". ### open interest @@ -524,6 +556,10 @@ A betting structure with no bookmaker: all stakes pool together, and winners spl An address computed from a program's ID and chosen seeds. A PDA is not a public key, so it has no private key, and no signature for it can ever exist. Only the deriving program can "sign" as it, by presenting the seeds for the runtime to recheck. +### permanent delegate + +A Token Extensions mint feature naming an authority that can move any holder's tokens of that mint, a seizure power some regulated assets require. See the *Tokens* chapter. + ### perpetual future A contract paying the price movement of an asset, long or short, with leverage and no expiry date; a [funding rate](#funding-rate) charges whichever side is heavier. The *Perpetual Futures* chapter builds one. @@ -560,6 +596,10 @@ The order-matching fairness rule: better-priced orders fill first, and among equ The amount lent or borrowed, before any interest. A lending program stores each loan's principal divided by the [borrow accumulation factor](#borrow-accumulation-factor) at the time of borrowing, and computes the live debt by multiplying it by the current factor. +### priority fee + +An extra fee per [compute unit](#compute-unit) that a transaction may add to be processed sooner, paid on every unit the transaction requests. See the *Core Building Blocks of Solana* chapter. + ### profit warm-up On a perpetual futures pool, the number of slots a position must be open before its profit can be taken. A trader who pushes the oracle to a false price cannot open a position and cash out the profit before the price has had that long to correct. Losses are not delayed. See the *Perpetual Futures* chapter. @@ -588,6 +628,14 @@ A [put](#put) bought by someone who already holds the underlying, as insurance a An [option](#option) giving its holder the right to sell the underlying at the [strike](#strike). Its writer's obligation is to buy; see [cash-secured put](#cash-secured-put). +### Pyth + +A [price feed](#price-feed) network that aggregates many independent publishers into one signed price with a [confidence interval](#confidence-interval). **Pyth Core** updates are signed by a multisig of five private keys Pyth holds, and anyone can post one to Solana for the [Pyth Receiver program](#pyth-receiver-program) to verify. **Pyth Pro**, the paid low-latency product, signs each price with a single private key, and the client carries the signed message into its own transaction. See the *Offchain Truth* chapter. + +### Pyth Receiver program + +The program that verifies a Pyth Core update's signatures and writes a `PriceUpdateV2` account it owns, which other programs read. See the *Offchain Truth* chapter. + ### quote See [quote token](#quote-token). @@ -606,11 +654,11 @@ A per-participant account, usually at a PDA, recording what the program owes one ### rent -The deposit an account holds to pay for its own storage. Rent-exempt accounts hold enough to stay forever; closing an account reclaims the deposit. +The deposit an account holds to pay for its own storage. An account funded to the **rent-exempt** minimum stays until it is closed, and an account cannot be created with less today; closing an account reclaims the deposit. ### reserve -In a lending market, the per-asset pool: its liquidity vault, its share mint, its interest state, and its risk configuration. +In an AMM, one of the two token balances a pool prices from. In a lending market, the per-asset pool: its liquidity vault, its share mint, its interest state, and its risk configuration. ### reserve factor @@ -622,11 +670,11 @@ A limit order that did not match on arrival and now waits on the book, funded an ### rounding -The decision every integer division makes about the remainder it cannot represent. Onchain this is policy rather than arithmetic: [floor](#floor) what the program pays out and [ceil](#ceil-ceiling) what the user owes, so the remainder always stays with the program. See [dust](#dust), [half-up](#half-up), and the *Arithmetic of Money* chapter. +The decision every integer division makes about the remainder it cannot represent. Onchain this is policy rather than arithmetic: [floor](#floor) what the program pays out and [ceil](#ceil-ceiling) what the user owes, so the remainder stays with the program. See [dust](#dust), [half-up](#half-up), and the *Arithmetic of Money* chapter. ### RPC endpoint -The URL a program or wallet sends its transactions to: a server holding a [cluster](#cluster)'s state and relaying transactions into it. The endpoint is what decides which cluster the code is talking to. +The URL an app or wallet sends its transactions to: a server holding a [cluster](#cluster)'s state and relaying transactions into it. The endpoint is what decides which cluster the code is talking to. ### RWA (real-world asset) @@ -648,6 +696,14 @@ One of the inputs a program combines with its program ID to derive a [PDA](#pda- A market maker quoting different spreads to different flow: tighter for flow it believes is uninformed, wider or none for flow it believes is [informed](#informed-flow). The *Prop AMM* chapter covers it as a defense against adverse selection. +### self-liquidation + +A borrower liquidating their own obligation. The *Lending and Borrowing* chapter's market allows it while the obligation is unhealthy (`owner_can_liquidate_their_own_obligation`). + +### senior / junior + +The order in which claims on a pool are paid. On the *Perpetual Futures* chapter's venue a trader's collateral is **senior**: it comes back, less losses and fees. Their profit is **junior**: it is paid only as far as the pool holds the tokens to pay it. See [haircut](#haircut). + ### settlement Paying out what a contract finally owes, and closing it. A betting market settles when the admin records the winning outcome and the losing pool is divided among the winners; a position settles when it closes and its [PnL](#pnl-profit-and-loss) becomes real balances. @@ -658,7 +714,7 @@ A token representing proportional ownership of a pool: of a lending reserve, of ### signer -An account whose private key signed the transaction. +An address that signed the transaction: a public key whose private key produced a signature, or a [PDA](#pda-program-derived-address) its program signed for by presenting the seeds. ### size ladder @@ -670,7 +726,7 @@ The difference between the price you expected and the price you got, caused by y ### slot -Solana's basic unit of time: a window in which one [leader](#leader) may produce a [block](#block). The network sets a target length, 200 milliseconds at the time of writing, and has shortened it before, so a slot count converts to seconds only at the slot length in force. A slot whose block does not arrive in time is skipped: the count moves on and that slot has no block. +Solana's unit of block production: a window in which one [leader](#leader) may produce a [block](#block). The network sets a target length, 200 milliseconds at the time of writing, and has shortened it before, so a slot count converts to seconds only at the slot length in force. A slot whose block does not arrive in time is skipped: the count moves on and that slot has no block. ### SOL @@ -690,7 +746,7 @@ What a pool quotes for a trade small enough to move nothing: the ratio of its tw ### spread -The gap between the best bid and the best ask. On an order book it emerges from the resting orders; a market maker **charges a spread** when it quotes its own prices around the one it takes as fair, and a prop AMM's spread is its entire fee. See the *Prop AMM* chapter. +The gap between the best bid and the best ask. On an order book it emerges from the resting orders; a market maker **charges a spread** when it quotes its own prices around the one it takes as fair, and a prop AMM's spread is its entire fee. The *Prop AMM* chapter's `spread_bps` is a half-spread: the ask is the oracle price plus it, and the bid the oracle price minus it. ### stablecoin @@ -710,7 +766,7 @@ Checking that an account a handler receives is the one recorded when the relatio ### strike -The price at which an [option](#option) trades if its holder exercises. +The price at which the [underlying](#underlying) changes hands if the holder [exercises](#exercise): what a [call](#call)'s holder pays per share, or a [put](#put)'s holder receives. ### swap router @@ -756,6 +812,10 @@ A time-weighted average price: the average of a market's price over a window, us The asset an [option](#option) or other derivative is about: what a [call](#call) buys and a [put](#put) sells at the [strike](#strike). It is short for "underlying asset", and every options market uses it as a noun on its own. In the *Options* chapter the underlying is NVDAx and the quote token is USDC. +### underwater + +Said of a loan or leveraged position past its liquidation line: an unhealthy lending obligation, or a perpetual position whose equity is at or below the [maintenance margin](#maintenance-margin). See [liquidation](#liquidation). + ### Unix timestamp The wall-clock time in the Clock [sysvar](#sysvar), in whole seconds. The previous block's [leader](#leader) writes it, within a range the network checks against that block's parent, so it is usually within a second or two of real time, at a value one leader chose. See the *Time* chapter. diff --git a/building-financial-products-in-solana/betting-market.md b/building-financial-products-in-solana/betting-market.md index be3b74d2..1114c574 100644 --- a/building-financial-products-in-solana/betting-market.md +++ b/building-financial-products-in-solana/betting-market.md @@ -5,11 +5,11 @@ What is going to be the top-grossing film this month? You have an opinion, and s - **With a bookmaker.** The house quotes odds, locks them in when each bet is placed, and takes the other side of every bet, so it carries the risk of paying winners more than the losers staked. This is fixed-odds betting, the model of a high-street betting shop or a sportsbook. - **Without a bookmaker.** Bettors are matched against each other, and the house takes a fee instead of a position. **Parimutuel** betting, also called pool betting or, in Britain and the Commonwealth, the tote, was devised in 1867 by Joseph Oller, who ran a horse-race betting office in Paris. It reached Churchill Downs in Kentucky by 1878 and spread to racetracks around the world. France's law of 2 June 1891 gave authorized racing societies the right to run parimutuel pools, and left the pool as the only legal way to bet on horses there.[^parimutuel-dates] Prediction markets such as Polymarket and Kalshi also have no bookmaker, but they match bettors on an order book, the design the *Order Book Exchange* chapter builds. -This chapter builds a parimutuel market. The program lives at `finance/betting-market` in the companion repository. Structurally it is the *Escrow* chapter's program with many parties and one new question: who decides what happened in the real world? +This chapter builds a parimutuel market. The program lives at `finance/betting-market` in the companion repository. Structurally it is the *Fundraiser* chapter's pooled vault with one new question: who decides what happened in the real world? ## Payouts Come From the Ratio of the Pools at the Close -In a parimutuel market, all stakes on all outcomes go into one vault, and when the event settles, the losing side's stakes (minus the house fee) are distributed to the winning side, based on the size of each winner's stake. Your payout odds are the ratio between the pools when betting closes, not the odds showing when you placed your bet, which means the odds float as people bet, and betting heavily on a favorite compresses everyone's payout. +In a parimutuel market, all stakes on all outcomes go into one vault, and when the event settles, the losing side's stakes (minus the house fee) are distributed to the winning side, based on the size of each winner's stake. Your payout odds are the ratio between the pools when betting closes, not the odds showing when you placed your bet, which means the odds float as people bet, and heavy betting on a favorite shrinks what each of its backers can win. **Parimutuel winnings are proportional to stake.** Bet three times as much as the next winner and you win three times as much of the losing pool. @@ -21,20 +21,20 @@ Maria is the admin. Her public key is recorded in the program's config, and only ![](figures/betting-maria-setup.svg) -Those five calls created the five accounts the market needs. +Those calls created five accounts: the config, the event and its vault, and two outcomes. -The **config** is a single account at a PDA (seed `"config"`), created once by `initialize_config()`, holding the `admin`, the `token_mint` bets are denominated in, the `fee_recipient`, the `default_fee_bps` each new event copies at creation, and a running `event_count`. The fee is capped at 10,000 basis points, because a fee above 100% is nonsense. +The **config** is a single account at a PDA (seed `"config"`), created once by `initialize_config()`, holding the `admin`, the `token_mint` bets are denominated in, the `fee_recipient`, the `default_fee_bps` each new event copies at creation, and a running `event_count`. The fee is capped at 10,000 basis points (100%), so it can never exceed the losing pool it is taken from. The **event** is an account at a PDA (seeds: `"event"`, the event ID) with a description, a status (Draft, Open, Settled, or Cancelled), the `betting_closes_at` time when betting stops, and the running `total_pool`. After settlement it also holds the `winning_outcome_index`, `winning_pool`, and `distributable_losing_pool`. Two design details: -- **Each event keeps the terms it was created with.** `initialize_event()` copies the config's `default_fee_bps` into the event's own `fee_bps` and sets `betting_closes_at` once, and settlement reads the event's copy, never the config. +- **Each event keeps the terms it was created with**, the *Offchain Truth* chapter's rule. `initialize_event()` copies the config's `default_fee_bps` into the event's own `fee_bps` and sets `betting_closes_at` once, and settlement reads the event's copy, never the config. - **The vault is one token account for the whole event**, an associated token account whose authority is the event PDA. Stakes on every outcome mix in one vault; the accounting that keeps them straight lives in the data accounts, not in separate pots. Each **outcome** is an account at a PDA (seeds: `"outcome"`, the event, an index byte) holding its label, its `total_amount` staked, and its `bet_count`. One rule protects the bettors: **the outcome list is fixed before any stake can arrive**. `add_outcome()` works only on a Draft and fails with `EventNotDraft` once the event is Open. `place_bet()` on a Draft fails with `EventNotOpen`, so nobody can bet while the list is still changing. `open_betting()` refuses a market with fewer than two outcomes (`NotEnoughOutcomes`), because a single outcome has no losing side to pay its winners from. The tests `test_outcomes_lock_when_betting_opens` and `test_open_betting_needs_two_outcomes` check all three rules. -A simpler but naive design would lock the list at the first bet, by refusing `add_outcome()` once the pool holds any stakes. That would let anyone freeze a half-built market with a one-minor-unit bet on its only outcome. It would also let Maria's last `add_outcome()` and the first bet land in either order, so the first bettor could stake on a list with an outcome their screen never showed. An explicit status that only Maria can change has neither problem, which is the *State Machines* chapter's rule: make the state a field the program writes, never something inferred from other fields. +A simpler but naive design would lock the list at the first bet, by refusing `add_outcome()` once the pool holds any stakes. That would let anyone freeze a half-built market with a one-minor-unit bet on its only outcome. It would also let Maria's last `add_outcome()` and the first bet land in either order, so the first bettor could stake on a list with an outcome their screen never showed. An explicit status that only Maria can change has neither problem, which is the *State Machines* chapter's rule: record the state explicitly, never infer it from other fields. ## Bettors Stake on Their Chosen Outcomes @@ -46,18 +46,18 @@ After all three bets, the vault holds 600 USDC: 400 staked on Glowbugs 3, 200 on ![](figures/betting-place-bets.svg) -A bettor's first bet on an outcome also created an account for them. Each **bet** is an account at a PDA (seeds: `"bet"`, the outcome, the bettor), holding the bettor, the outcome index, and the amount. One bet account per bettor per outcome: betting again on the same outcome tops up the existing account rather than creating a second one. The bettor's address is the first field after the account's discriminator, so a wallet app lists someone's open bets by asking an RPC node for every bet account with their address at that offset (`getProgramAccounts` with a `memcmp` filter). The program keeps no list of a wallet's bets, so it sets no limit on how many a wallet holds open at once (checked by `test_no_cap_on_open_bets_per_wallet`). +Each **bet** is an account at a PDA (seeds: `"bet"`, the outcome, the bettor), holding the bettor, the outcome index, and the amount. The bettor's address is the first field after the account's discriminator, so a wallet app lists someone's open bets by asking an RPC node for every bet account with their address at that offset (`getProgramAccounts` with a `memcmp` filter). The program keeps no list of a wallet's bets, so it sets no limit on how many a wallet holds open at once (checked by `test_no_cap_on_open_bets_per_wallet`). ## The Film Grosses: The Operator Settles the Event -The month ends, and betting closes at midnight. In the real world, Glowbugs 3 wins. In program terms, the fact that decides where 600 USDC goes exists entirely offchain. This program resolves it by having a designated trusted party sign. Maria calls `settle_event()` with the winning outcome's index, her signature is checked against the config's admin, and her answer is written to the event account as `winning_outcome_index`. The *Offchain Truth* chapter covers the full spectrum, from a single admin signer like this one, through multi-party resolution, to signed price feeds. This program makes the trusted party explicit and limits what their signature can do. Maria can pick which outcome won, but she cannot touch the payout math, take more than the snapshotted fee, or reach into the vault by any other path. +The month ends, and betting closes at midnight. In the real world, Glowbugs 3 wins. In program terms, the fact that decides where 600 USDC goes exists entirely offchain. This program resolves it by having a designated trusted party sign. Maria calls `settle_event()` with the winning outcome's index, her signature is checked against the config's admin, and her answer is written to the event account as `winning_outcome_index`. The *Offchain Truth* chapter covers the full spectrum, from a single admin signer like this one, through multi-party resolution, to signed price feeds. This program makes the trusted party explicit and limits what their signature can do. Maria can pick which outcome won, but she cannot touch the payout math, take more than the fee fixed at creation and the dust the floors leave, or reach into the vault by any other path. -`settle_event()` works only once the clock reaches `betting_closes_at`, and earlier fails with `BettingStillOpen`. The two windows split time exactly: a bet needs the clock before the close, and settlement needs it at or after. So no bet can land once the event can be settled, and Maria cannot end the market early (checked by `test_betting_closes_at_close_time`). +`settle_event()` works only once the clock reaches `betting_closes_at`, and earlier fails with `BettingStillOpen`. A bet needs the clock before the close and settlement needs it at or after, so no bet can land once the event can be settled, and Maria cannot end the market early (checked by `test_betting_closes_at_close_time`). Settlement does four things, none of which involve paying bettors yet: - Marks the event Settled, so `place_bet()` now rejects with `EventNotOpen` (checked by `test_cannot_bet_after_settle`). -- Validates the winner structurally: the winning outcome account is re-derived from the index via its PDA seeds, and an outcome nobody bet on rejects with `OutcomeHasNoBets`, because a winning pool of zero would put a divide-by-zero in every claim (checked by `test_settle_outcome_without_bets_fails`). +- Validates the winner structurally: the winning outcome account is re-derived from the index via its PDA seeds. An outcome nobody bet on rejects with `OutcomeHasNoBets`: with no winning bets nobody could claim the losing pool, and `close_event()` would pay it all to the fee recipient (checked by `test_settle_outcome_without_bets_fails`). - Does the fee math. The losing pool is `total_pool` minus the winning outcome's total: 600 − 400 = 200 USDC. The fee is 2% of the *losing pool only*: 4 USDC, computed in u128 and rounded up. The remaining 196 becomes the `distributable_losing_pool`, stored on the event. Charging the fee only on the losing side gives one guarantee: **a winner can never receive less than they staked**. - Transfers the 4 USDC fee out of the vault to the fee recipient's token account, signed by the event PDA. @@ -75,17 +75,19 @@ None of these payouts depended on when anyone bet. Had Alice bet first, when Glo The multiply runs in u128 and divides once, after the multiplication, so the result is floored a single time; dividing first would throw away precision on every claim. The floor means the payouts can fall a few minor units short of the distributable pool; that dust stays in the vault, where no claimant can reach it. -The claim closes the bet account, refunding its rent to the bettor. **A closed account stops a double claim**: Alice claiming twice fails because the bet account the second claim needs is already gone (`test_double_claim_fails` asserts the second claim fails with Anchor's `AccountNotInitialized`), and there is no `claimed` flag to forget to check. +The claim closes the bet account, refunding its rent to the bettor. As the *State Machines* chapter's rule has it, **a closed account stops a double claim**. Alice claiming twice fails because the bet account the second claim needs is already gone (`test_double_claim_fails` asserts the second claim fails with Anchor's `AccountNotInitialized`), and there is no `claimed` flag to forget to check. ![](figures/betting-alice-claim.svg) ## Closing Out, and Getting Assets Out of Every State -Carol's losing bet pays nothing, but its account still holds her rent. She calls `close_losing_bet()`, which checks the event is settled and her outcome is not the winner (a winner trying to use this path for losing bets rejects with `BetWon` and must use `claim_winnings()`), then closes the account and returns the rent to her. `test_close_losing_bet_only_after_settle_and_only_for_losers` checks that a close before settlement and a winner's close both fail. +Carol's losing bet pays nothing, but its account still holds her rent. She calls `close_losing_bet()`, which checks the event is settled and her outcome is not the winner (called on a winning bet, it fails with `BetWon`; a winner closes their bet through `claim_winnings()`), then closes the account and returns the rent to her. `test_close_losing_bet_only_after_settle_and_only_for_losers` checks that a close before settlement and a winner's close both fail. -Bob claims his 149 USDC the same way, and once Carol's account closes no bet of the event is open. Maria then closes what the event created, children first: every bet before any outcome, and every outcome before the event. She calls `close_outcome()` once per outcome. It refuses while the event is still a Draft or Open (`EventNotFinished`) and while the event's `open_bets` is above zero (`BetsStillOpen`); otherwise it closes the outcome account and returns its rent to her. No claim or refund reads the outcome account, because each finds its bet from the outcome address the bet stores. The check keeps the closing order: an outcome stays open while any bet still names its address. Then she calls `close_event()`, which makes the same two checks and also refuses while any outcome is open (`OutcomesStillOpen`): an outcome account left behind would block a later event created with the same event ID from adding its first outcome. It pays whatever the vault still holds to the fee recipient's token account, closes the vault, and closes the event account, returning both rents to her. +Bob claims his 149 USDC the same way, and once Carol's account closes no bet of the event is open. Maria then closes what the event created, children first: every bet before any outcome, and every outcome before the event. The event counts its open outcomes and bets and refuses to close until both are zero, the *Fundraiser* chapter's rule for an account with derived accounts. `add_outcome()` raises `open_outcomes` and `close_outcome()` lowers it; `place_bet()` raises `open_bets` for each bet account it creates, and `claim_winnings()`, `claim_refund()`, and `close_losing_bet()` each lower it. -The event knows whether it may close from two counters: `open_outcomes`, which `add_outcome()` raises and `close_outcome()` lowers, and `open_bets`, which `place_bet()` raises for each bet account it creates and `claim_winnings()`, `claim_refund()`, and `close_losing_bet()` each lower. In this market the vault is already empty: 147 and 49 divided exactly. Where the floors leave dust, `close_event()` pays it to the fee recipient, so it ends up where the fee did. +She calls `close_outcome()` once per outcome. It refuses while the event is still a Draft or Open (`EventNotFinished`) and while the event's `open_bets` is above zero (`BetsStillOpen`); otherwise it closes the outcome account and returns its rent to her. No claim or refund reads the outcome account, because each finds its bet from the outcome address the bet stores. `BetsStillOpen` keeps the order: no outcome closes while any bet of the event is open, so no open bet names a closed outcome. Then she calls `close_event()`, which makes the same two checks and also refuses while any outcome is open (`OutcomesStillOpen`). It pays whatever the vault still holds to the fee recipient's token account, closes the vault, and closes the event account, returning both rents to her. + +In this market the vault is already empty: 147 and 49 divided exactly. Where the floors leave dust, `close_event()` pays it to the fee recipient, so it ends up where the fee did. `test_close_event_pays_dust_to_fee_recipient_and_returns_rent` runs a market whose shares do not divide exactly, and checks that the one minor unit left reaches the fee recipient, that every closed account is gone, and that each rent returns to the admin. @@ -104,11 +106,11 @@ The financial arithmetic of this program is four expressions: - **Each winner's share**: a u128 multiply of stake by distributable pool, then one floor division by the winning pool. - **The payout**: stake plus share, checked. -Every checked operation maps to `MathOverflow`. There are no rates and no curves, and time appears only as one comparison against the close. +Every checked operation maps to `MathOverflow`. There are no rates and no curves, and time appears only in comparisons of the clock against `betting_closes_at`. ## The Invariants: The Vault Covers Every Claim -- At every moment, the vault balance equals stakes not yet claimed: before settlement, the total pool; after settlement, winning stakes plus unclaimed winnings plus dust; after cancellation, the stakes not yet refunded. +- At every moment, the vault holds at least the stakes not yet claimed: before settlement, the total pool; after settlement, the unclaimed winning stakes and their winnings; after cancellation, the stakes not yet refunded. `close_event()` pays whatever else the vault holds to the fee recipient. - The winning pool, the distributable losing pool, and the fee always sum exactly to the pre-settlement total pool: 400 + 196 + 4 = 600. - A winner's payout is never less than their stake. - One bet account per bettor per outcome, and no bet account survives its payout, refund, or close. @@ -119,7 +121,7 @@ Every checked operation maps to `MathOverflow`. There are no rates and no curves ## The Failure Modes: One Person Decides the Result - **The resolution itself.** Maria could settle the wrong outcome, maliciously or by mistake. This program is the simplest case; the *Offchain Truth* chapter covers what a trusted party forfeits by lying, and how to replace a single signer with several. -- **Settling an empty outcome** would divide by zero in every claim; rejected at the source with `OutcomeHasNoBets`. +- **Settling an empty outcome** would hand every stake to the fee recipient; rejected with `OutcomeHasNoBets`. - **Double claims** fail because the bet account is gone, as the claim step showed. - **Closing an account too early** fails with the three checks the close step showed, `EventNotFinished`, `BetsStillOpen` and `OutcomesStillOpen` (`test_close_event_refused_while_event_is_open` checks the first, `test_close_event_refused_while_a_bet_is_open` the second, and `test_close_event_pays_dust_to_fee_recipient_and_returns_rent` the third). Only Maria's signature closes anything (`test_only_admin_can_close_outcomes_and_event`). - **Changing the terms after betting opens** fails: `add_outcome()` on an Open event fails with `EventNotDraft`, and `fee_bps` and `betting_closes_at` have no handler that writes them after `initialize_event()`. @@ -134,15 +136,12 @@ The suite builds all state through the program's own handlers, and the longest t A racetrack's parimutuel pool needs a tote board, a licensed operator, and a payout window. Here the vault holds every stake, the payout formula sets each winner's share from the pools at the close, and the fee is 2% of the losing pool. -This chapter added six concepts: +This chapter added three concepts: - **The parimutuel structure.** All stakes pool together; winners split the losing pool in proportion to their stakes, so a larger stake wins more and no one sets odds. -- **A designated signer decides the result.** The operator acts as the oracle, and the program lets the signature choose the winner and nothing else. -- **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.** `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. +- **Two levels of derived accounts close in order.** Bets close before outcomes and outcomes before their event, because each close refuses while a counter on the event shows a child 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. +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 trusted signer returns later, first in the *Prop AMM* chapter, where the trusted signature is a price feed. The pooled payout returns in the *Lending and Borrowing* and *Perpetual Futures* chapters, where settlement runs continuously instead of once. [^parimutuel-dates]: Oller's 1867 system is recorded in the Encyclopaedia Universalis entry on him, at [universalis.fr/encyclopedie/joseph-oller](https://www.universalis.fr/encyclopedie/joseph-oller/); the 1878 Churchill Downs introduction by the Kentucky Derby, at [kentuckyderby.com/horses/news/how-kentucky-derby-betting-traditions-have-changed-over-time-since-1875](https://www.kentuckyderby.com/horses/news/how-kentucky-derby-betting-traditions-have-changed-over-time-since-1875/); and the law of 2 June 1891, whose article 5 lets approved societies organize the pari mutuel, at [weka.fr/base-juridique-weka/loda/JORFTEXT000000512373](https://www.weka.fr/base-juridique-weka/loda/JORFTEXT000000512373). diff --git a/building-financial-products-in-solana/constant-product-amm.md b/building-financial-products-in-solana/constant-product-amm.md index 0958ad34..a8760e11 100644 --- a/building-financial-products-in-solana/constant-product-amm.md +++ b/building-financial-products-in-solana/constant-product-amm.md @@ -2,15 +2,17 @@ The *Order Book Exchange* chapter built an exchange where every price is a commitment someone posted. This chapter builds the other kind: an **automated market maker**, where nobody posts prices at all. Every incoming order fills instantly, at a price determined by a formula, against tokens supplied by **liquidity providers** who earn a fee on every trade. That fee compensates them for impermanent loss, the amount by which providing liquidity falls behind holding the same tokens when the price moves, and this chapter calculates it. -AMMs work where order books do not: tokens without much trading volume, where a taker might wait forever for a matching maker. So they are the standard venue for new token launches, and this chapter follows one. Orca, Raydium, and Pump.fun are popular AMMs on Solana. The program lives at `finance/token-swap` in the companion repository. +AMMs work where order books do not: tokens without much trading volume, where a taker might wait forever for a matching maker. So they are the standard venue for new token launches, and this chapter follows one. Orca and Raydium are popular AMMs on Solana. The program lives at `finance/token-swap` in the companion repository. ## X Times Y Equals K, and K Prices Every Trade -The pool holds two tokens. Call the amount of the first one **X** (here, ACME, the freshly listed stock of a robotics startup) and the amount of the second **Y** (USDC, because you quote in the stable asset). **K is X times Y.** Its only job is to hold X and Y in relation to each other during a trade: a buyer who adds USDC to the pool gets exactly the amount of ACME that keeps the product steady at K. Rearranged, that is the pricing formula: +The pool holds two tokens. Call the amount of the first one **X** (here, ACME, the freshly listed stock of a robotics startup) and the amount of the second **Y** (USDC, because you quote in the stable asset). **K is X times Y.** During a trade the program holds K fixed: a buyer who adds USDC gets the amount of ACME that keeps X times Y at K, rounded down. Rearranged, that is the pricing formula: output = taxed_input × other_reserve ÷ (this_reserve + taxed_input) -The spot price is the ratio Y ÷ X, and every trade moves it: buying ACME removes X and adds Y, so the next buyer pays more. Big trades relative to the pool move the price a lot; that is price impact. +where taxed_input is the trader's input minus the trading fee, and the reserves are the pool's balances of the input and output tokens. + +The spot price is the ratio Y ÷ X, and every trade moves it: buying ACME removes X and adds Y, so the next buyer pays more. Big trades relative to the pool move the price a lot; that is price impact, and a deeper pool has less of it. **The curve holds K fixed while it prices a trade, and K changes between trades.** It grows two ways: @@ -19,8 +21,6 @@ The spot price is the ratio Y ÷ X, and every trade moves it: buying ACME remove Liquidity withdrawals shrink K, by the withdrawer's share of both reserves. -The deeper the pool, the less any one trade moves the price. - ![](figures/amm-curve.svg) ## Creating the Venue Sets the Launch Price @@ -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: 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 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 allows K to grow, because the LP fee and rounding dust both add to the reserves. 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. @@ -75,13 +75,13 @@ Carol has watched the launch all morning and buys $50 of ACME even though the pr ## Selling Back Pays the Fee in the Input Token -Bob sells 20 of his 40.7 ACME. A sell is the same instruction with `input_is_token_a` flipped. Now the fee comes off his ACME input (0.06 ACME, of which 0.01 accrues to `admin_fees_owed_a`), X grows by the taxed amount, and Y falls to keep the product: about 55.11 USDC to Bob, an average near $2.76 against the $2.46 he paid. The spot price falls back to about $2.64. The admin's fee ledger now holds claims in *both* tokens, which is why the claim instruction sweeps two sides. +Bob sells 20 of his 40.7 ACME. A sell is the same instruction with `input_is_token_a` flipped. Now the fee comes off his ACME input (0.06 ACME, of which 0.01 accrues to `admin_fees_owed_a`). The curve prices against X plus the taxed 19.94 ACME, the vault receives all 20, and Y falls to keep the product: about 55.11 USDC to Bob, an average near $2.76 against the $2.46 he paid. The spot price falls back to about $2.64. The admin's fee ledger now holds claims in *both* tokens, which is why the claim instruction sweeps two sides. ![](figures/amm-bob-sell.svg) ## The Admin Sweeps Its Share of the Fees -Whenever she likes, Maria runs `claim_admin_fees()`. It zeroes both owed counters and transfers exactly those amounts from the vaults to her token accounts, signed by the pool config; anyone else is rejected (`test_claim_admin_fees_rejects_non_admin`), and claiming when nothing is owed fails with `NothingToClaim`. One transfer per token, however many swaps accrued it: the same consolidation logic as the order book's unsettled balances. +Whenever she likes, Maria runs `claim_admin_fees()`. It zeroes both owed counters and transfers exactly those amounts from the vaults to her token accounts, signed by the pool config; anyone else is rejected (`test_claim_admin_fees_rejects_non_admin`), and claiming when nothing is owed fails with `NothingToClaim`. One transfer per token, however many swaps accrued it, as the order book's `settle_funds()` pays out a user's unsettled balances, however many fills accrued them. ![](figures/amm-maria-claims.svg) @@ -89,11 +89,11 @@ Whenever she likes, Maria runs `claim_admin_fees()`. It zeroes both owed counter Alice wants her capital back. `withdraw_liquidity()` pays her a proportional slice of both effective reserves and burns her LP tokens, dividing by the LP supply *plus* `MINIMUM_LIQUIDITY`, the same divisor her deposit was minted against. The locked floor is backed by reserves but absent from the supply counter. Leaving it out here would overpay every withdrawer, and leaving it out of the deposit would underpay every depositor. -Alice does not get back what she deposited. She deposited 100 ACME and 225 USDC; the pool's mix has shifted since, so her slice is about 92.3 ACME and 244 USDC. Valued at the current price, that is roughly $1.50 *less* than if she had held her original tokens in her wallet. A third of a percent is $1.50 on her position and $1.5 million on a position a million times larger. +Alice does not get back what she deposited. She deposited 100 ACME and 225 USDC; the pool's mix has shifted since, so her slice is about 92.3 ACME and 244 USDC. Valued at the current price, that is roughly $1.50 *less* than if she had held her original tokens in her wallet. ![](figures/amm-alice-withdraw.svg) -That gap is **impermanent loss**, and every LP is exposed to it. A constant-product pool mechanically sells the token that is rising and accumulates the token that is falling, so an LP always slightly underperforms just holding, whenever the price moves in either direction. **An LP position profits from trading volume, not from price direction**: if accumulated fees outrun the divergence, LPing wins; if the price moves far in one direction on low volume, holding wins. It is called "impermanent" because it only crystallizes at withdrawal; had Alice stayed in and ACME returned to $2.25, there would have been no loss. +That gap is **impermanent loss**, and every LP is exposed to it. A constant-product pool mechanically sells the token that is rising and accumulates the token that is falling, so an LP underperforms just holding whenever the price moves, and by more the further it moves. **An LP position profits from trading volume, not from price direction**: if accumulated fees outrun the divergence, LPing wins; if the price moves far in one direction on low volume, holding wins. It is called "impermanent" because it only crystallizes at withdrawal; had Alice stayed in and ACME returned to $2.25, there would have been no loss. ![](figures/amm-impermanent-loss.svg) @@ -108,11 +108,11 @@ Fee income is what an LP is paid for carrying that gap, and the gap is small for | $4.50 (doubles) | $675 | −$39 | −$29 | +$11 | +$161 | | $9.00 (quadruples) | $1,125 | −$225 | −$215 | −$175 | −$25 | -Across any row, LPing wins once fee income outruns the gap, and for ordinary moves it does not take much: $10 of fees covers a move of a quarter in either direction. Down the "LP, no fees" column, with nobody paying fees, the LP is behind in every row but one; that is the solid curve in the figure, and a pool that pays nothing for the risk it asks LPs to carry. Down the last column the LP is still behind when the price quadruples: a fourfold move opens a $225 gap, wider than a year of $200 in fees can cover. +Across any row, LPing wins once fee income outruns the gap, and for ordinary moves it does not take much: $10 of fees covers a move of a quarter in either direction. Down the "LP, no fees" column, the solid curve in the figure, the LP is behind in every row but one: with no fee income, nothing pays LPs for that risk. Down the last column the LP is still behind when the price quadruples: a fourfold move opens a $225 gap, wider than a year of $200 in fees can cover. ## The Math: Floors Everywhere, and a Square Root by Newton -Every division in this program rounds against the trader. Swap output, LP minting, and withdrawal amounts floor **in the pool's favor**, and the fee and the admin's share of it round up, so a trader never pays a minor unit less than the rate (`test_swap_fee_rounds_up`). The dust stays in the reserves and accrues to all LPs, the direction the *Arithmetic of Money* chapter sets. Every multiplication runs in u128 before its division (multiply before divide), and every step that can overflow is `checked_*` with `MathOverflow` on failure. The only unusual operation, the first deposit's square root in `initialize_pool()`, is a short loop of Newton's method on integers, floored like everything else. +Every division between the pool and a trader or LP rounds **in the pool's favor**: swap output, LP minting and withdrawal amounts floor, and the fee rounds up, so a trader never pays a minor unit less than the rate (`test_swap_fee_rounds_up`). The admin's share of the fee also rounds up, against the LPs. The dust stays in the reserves and accrues to all LPs, the direction the *Arithmetic of Money* chapter sets. Every multiplication runs in u128 before its division (multiply before divide), and every step that can overflow is `checked_*` with `MathOverflow` on failure. The only unusual operation, the first deposit's square root in `initialize_pool()`, is a short loop of Newton's method on integers, floored like everything else. `swap_tokens()` takes an exact input and a minimum output, so each trade can be written only one way. Many AMMs also accept the reverse, an exact output and a maximum input, and then one trade, such as 100 USDC for about 40.7 ACME, can be sent either way. Both handlers must charge the same fee and round in the pool's favor by the same rule, or traders route every trade through whichever costs less. If your AMM has both, test it by pricing one trade both ways and asserting that the trader pays the same input for the same output. @@ -125,7 +125,7 @@ Every division in this program rounds against the trader. Swap output, LP mintin ## The Failure Modes: Empty Pools, First Deposits, and the Vacuous Invariant -- **The empty-reserve drain.** If one reserve were zero, the swap formula outputs the *entire* other reserve (output = input × other ÷ (0 + input) = other). The invariant check would not catch it either, because the pre-trade K would be 0 × other = 0, and "new K ≥ 0" holds vacuously. A check requires both effective reserves positive (`EmptyPoolReserve`) before any pricing runs, in the swap and in the deposit, which would otherwise take the depositor's ratio as the price of a pool that has none. No instruction reaches that state, so `test_pool_creation_cannot_be_front_run` puts a pool there by hand, zeroing a vault's balance, and checks the refusal. +- **The empty-reserve drain.** If one reserve were zero, the swap formula would output the *entire* other reserve (output = input × other ÷ (0 + input) = other). The invariant check would not catch it either, because the pre-trade K would be 0 × other = 0, and "new K ≥ 0" holds vacuously. A check requires both effective reserves positive (`EmptyPoolReserve`) before any pricing runs, in the swap and in the deposit, which would otherwise take the depositor's ratio as the price of a pool that has none. No instruction reaches that state, so `test_pool_creation_cannot_be_front_run` puts a pool there by hand, zeroing a vault's balance, and checks that a deposit is refused. - **The inflation attack.** The `MINIMUM_LIQUIDITY` floor makes the inflation attack from the *Adversary* chapter uneconomic. The smallest pool an attacker can open leaves them 1 minor unit of LP out of 101 units of supply. Rounding a deposit down to zero then takes a donation of about 101 times that deposit, and 100/101 of the donation belongs to units nobody can redeem. The floor has to be in both divisors: a deposit divided by the bare supply of 1 rounds down against a donation merely as large as itself. `test_donation_cannot_round_a_later_deposit_to_zero` runs the attack with a donation of 1,000,000 minor units to each vault: the victim's equal deposit is minted 100 minor units of LP, and the attacker's single unit redeems 9,950 minor units of token A. - **The first deposit sets the price, so the creator makes it.** Every later deposit is clamped to the creator's price. `test_pool_creation_cannot_be_front_run` opens Maria's pool and has a second wallet deposit 400 ACME against 100 USDC in the next transaction: the USDC binds, 44.44 of the ACME is taken, and the pool still prices at $2.25 to the cent. - **Slippage and sandwiches.** Every trade and deposit carries a caller-supplied minimum (`min_output_amount`, `minimum_lp_tokens_out`, the withdrawal minimums), and the tests check that each one rejects (`SlippageExceeded`, `DepositBelowMinimum`, `WithdrawalBelowMinimum`). What the program cannot do is choose the number for you; a zero minimum is legal and is what a sandwich attacker hopes you send. @@ -137,7 +137,7 @@ The suite (`tests/test_swap.rs`) covers the creation, deposit, swap, withdrawal, ## What You Learnt from Building the Constant-Product AMM -The order book needed a tree, a matching engine, and maker accounts, because humans were quoting the prices. The AMM replaced all of it, and the market makers themselves, with a pricing formula: instant liquidity for anything, priced by a curve, funded by anyone willing to earn fees for supplying it. +The order book needed two trees, a matching engine, and maker accounts, because humans were quoting the prices. The AMM replaced all of it, and the market makers themselves, with a pricing formula: a pool that fills any trade at once, priced by a curve, funded by anyone willing to earn fees for supplying it. Seven concepts came with the curve: diff --git a/building-financial-products-in-solana/escrow.md b/building-financial-products-in-solana/escrow.md index 03062fd4..0f14a95b 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 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. +With three instruction handlers, an escrow program lets two people swap tokens without trusting each other or paying a middleman. 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. @@ -12,13 +12,13 @@ Alice starts, like every character in this book, with one SOL and 1,000 USDC; sh ![](figures/escrow-swap-intent.svg) -The naive version is that Alice sends Bob her USDC and waits, and Bob can take the USDC and leave. An **escrow** lets Alice and Bob swap without having to trust each other. +The naive version is that Alice sends Bob her USDC and waits, and Bob can take the USDC and leave. An **escrow** holds one side's tokens until the other side pays. Traditional finance solves this with a trusted third party. Alice sends her funds to the third party, which holds them in escrow until Bob has done the same, then distributes both sides to their destinations. And the third party charges a fee for the service, taken from the amount it holds. ![](figures/escrow-traditional-escrow.svg) -A Solana program replaces the third party. The program holds Alice's tokens in a vault, and only the program's own code can move them out of it. There is no operator in this program at all: whoever deploys it earns nothing from it, takes no fee, and holds no private key that can sign for the vault. Each side receives one hundred percent of what the other side offered. Solana's transaction fees still apply, but they are a small fraction of a cent regardless of the size of the trade. +A Solana program replaces the third party. The program holds Alice's tokens in a vault, and only the program's own code can move them out of it. There is no operator in this program at all: whoever deploys it earns nothing from it, takes no fee, and holds no private key that can sign for the vault. Each side receives one hundred percent of what the other side offered. Solana's transaction fee still applies, with a base fee of 5,000 lamports per signature at the time of writing, whatever the size of the trade. ![](figures/escrow-program-swap.svg) @@ -33,7 +33,7 @@ The deal is priced in minor units. USDC has six decimals, so Alice's 250 USDC is ## The Instruction Handlers -The program has three instruction handlers, and together they form the offer's complete state machine: `make_offer()` creates the offer and specifies what is wanted in return, and the offer stays open until someone else runs `take_offer()` or the maker runs `cancel_offer()`. As the *State Machines* chapter requires, an open offer can be ended by either side. +The program has three instruction handlers, and together they form the offer's complete state machine: `make_offer()` creates the offer and specifies what is wanted in return, and the offer stays open until someone else runs `take_offer()` or the maker runs `cancel_offer()`. As the *State Machines* chapter requires, the maker can get their tokens out of an open offer even if nobody takes it. ![](figures/escrow-state-machine.svg) @@ -45,7 +45,7 @@ Alice calls `make_offer()` with three arguments: her chosen `id`, `token_a_offer This one transaction created the two accounts the deal needs, and a third for Alice, covered below. -The **offer account** stores what Alice wants in return. It is the program's custom data, defined in `state/offer.rs`: +The **offer account** stores what Alice wants in return. It is the program's custom data, defined in `anchor-v1/programs/escrow/src/state/offer.rs`: - `id`, the number Alice chose, so one maker can have many offers open at once - `maker`, Alice's address, recorded so the program knows who may cancel and who receives payment @@ -75,7 +75,7 @@ Bob calls `take_offer()`. The offer account Bob passes names the offer he is tak ![](figures/escrow-take-offer.svg) -Alice takes no part in this transaction. She does not need to be awake, online, or anywhere in particular. She signed her part when she made the offer; the program does the rest. +Alice takes no part in this transaction. She does not need to be online: her signature on `make_offer()` already moved her USDC into the vault. The handler code runs three steps: @@ -89,17 +89,17 @@ Both sides now hold what the other offered. ### The Maker Cancels the Offer -If nobody takes the offer, Alice needs her USDC back. Without a handler that returns them, her tokens would sit in the vault forever. That handler is `cancel_offer()`. +If nobody takes the offer, Alice needs her USDC back. Without a handler that returns it, her USDC would stay in the vault. That handler is `cancel_offer()`. ![](figures/escrow-cancel-offer.svg) -The constraints make it maker-only: the signer must match the maker address stored in the offer. Bob cannot cancel Alice's offer, which `test_cancel_offer_rejects_non_maker` checks. `cancel_offer()` mirrors the second half of `take_offer()`: it signs with the offer's seeds to move the full vault balance back to `maker_token_account_a`, and closes both the vault and the offer account, returning both rent deposits to Alice. +The constraints make it maker-only. The offer's address is re-derived from the signer's address, so when Bob signs, the seeds no longer match and Anchor refuses with `ConstraintSeeds`, which `test_cancel_offer_rejects_non_maker` checks. A stored-address check also compares the signer with the offer's stored maker. `cancel_offer()` mirrors the second half of `take_offer()`: it signs with the offer's seeds to move the full vault balance back to `maker_token_account_a`, and closes both the vault and the offer account, returning both rent deposits to Alice. ## The Math: No Division, So No Rounding -The offer is all-or-nothing, so there are no partial fills, no division, and no rounding policy to decide. The amounts the maker states are the amounts that move. There is no multiplication either. Every transfer goes through the shared `transfer_tokens()` helper, which uses `transfer_checked()`, the decimals-checking transfer the *Tokens* chapter describes. +The offer is all-or-nothing, so there are no partial fills, no division, and no rounding policy to decide. Token B moves in the amount the offer stores; token A moves as the vault's whole balance, which is the offered amount unless someone has transferred more in. There is no multiplication either. Every transfer goes through the shared `transfer_tokens()` helper, which uses `transfer_checked()`, the decimals-checking transfer the *Tokens* chapter describes. -The lamport math that remains is rent, under the *State Machines* chapter's rule: whoever opens an account pays its deposit and gets it back. Alice fronts the rent deposits for the offer account and the vault (and for her own token B account if it did not exist). When her token B account already existed, she gets back exactly the lamports she paid in, and the tests assert that to the lamport. Bob pays rent only for his own token A account if he never held that token, and that account remains his afterward. Nobody's rent ends up with the other party. +The lamport math that remains is rent, under the *State Machines* chapter's rule: whoever opens an account pays its deposit and gets it back. Alice pays the rent deposits for the offer account and the vault (and for her own token B account if it did not exist). When her token B account already existed, she gets back exactly the lamports she paid in, and the tests assert that to the lamport. Bob pays rent only for his own token A account if he never held that token, and that account remains his afterward. Nobody's rent ends up with the other party. ## The Invariants: Tokens Are in Exactly One Place @@ -109,7 +109,7 @@ Five things must hold before and after every handler: - The vault holds at least `token_a_offered_amount` from the end of `make_offer()` until `take_offer()` or `cancel_offer()` runs, and both handlers empty the vault completely before closing it. A direct donation could push the balance above that amount. The only check that reads the balance compares it with the taker's `minimum_token_a_out`, and whatever is in the vault goes to the taker on a take or back to the maker on a cancel. - The offer account's data never changes after `handle_save_offer()` writes it. There is no update handler. - Only the taker's signature moves token B, and only the offer PDA's signature moves the vault. -- Rent returns to whoever paid it, on every path. +- The offer's and the vault's rent return to the maker on every path. ## The Failure Modes: Every Substitution Is Checked @@ -121,20 +121,20 @@ Five things must hold before and after every handler: - **A mismatched mint.** Bob creates a token of his own, mints himself 100,000,000 minor units of it, and passes its mint as `token_mint_b` and his account of it as `taker_token_account_b`, hoping to pay Alice in a token worth nothing. The stored-address check compares the mint passed in against the `token_mint_b` recorded in the offer, and rejects it. Each is blocked by a constraint: associated token account derivation binds each token account to its owner and mint, stored-address checks bind the maker and mints to the offer's stored data, and seeds plus bump bind the offer to the program. The *Adversary* chapter shows what happens to programs that skip these checks. -- **Unauthorized cancel.** `cancel_offer()` requires the maker's signature and checks it against the offer's stored maker. -- **Degenerate offers.** An offer with zero tokens on one side, or of a token for a different amount of itself, means someone is about to be cheated. `make_offer()` refuses an offer of zero tokens on either side with `ZeroAmount` (`test_make_offer_rejects_zero_offered_amount` and `test_make_offer_rejects_zero_wanted_amount`). An offer of a token for itself never reaches the handler: with one mint, the maker's token A and token B accounts are the same account, and Anchor refuses the same writable account twice with `ConstraintDuplicateMutableAccount`, which `test_make_offer_rejects_same_mint` checks. +- **Unauthorized cancel.** `cancel_offer()` re-derives the offer's address from the signer's address, and checks the signer against the offer's stored maker. +- **Degenerate offers.** An offer with zero tokens on one side gives one party something for nothing, and an offer of a token for itself is not a swap. `make_offer()` refuses an offer of zero tokens on either side with `ZeroAmount` (`test_make_offer_rejects_zero_offered_amount` and `test_make_offer_rejects_zero_wanted_amount`). An offer of a token for itself never reaches the handler: with one mint, the maker's token A and token B accounts are the same account, and Anchor refuses the same writable account twice with `ConstraintDuplicateMutableAccount`, which `test_make_offer_rejects_same_mint` checks. ## Testing Considerations -The tests live in `tests/test_escrow.rs`, creating the wallets, mints, and token accounts each scenario needs. Every piece of program state in the tests is created through the program's own instruction handlers, never injected, as the testing section of the *Operating Financial Software* chapter requires. +The tests live in `anchor-v1/programs/escrow/tests/test_escrow.rs`, creating the wallets, mints, and token accounts each scenario needs. Every piece of program state in the tests is created through the program's own instruction handlers, never injected, as the testing section of the *Operating Financial Software* chapter requires. - `test_make_offer` calls `make_offer()` and then checks both effects: the vault holds the offered amount, and the offer account's stored fields (ID, maker) match what Alice sent. -- `test_take_offer` runs the full happy path and asserts both directions of the swap in both tokens: Bob gains 250 USDC and pays his 1 TSLAx, and Alice gains 1 TSLAx and is left with 750 USDC. Assert both directions of every symmetric flow, as the testing section of the *Operating Financial Software* chapter says. It then asserts the vault and offer accounts are gone, and that Alice's lamports round-tripped exactly, and so checks that no rent ended up with the wrong party. +- `test_take_offer` runs the full happy path and asserts both directions of the swap in both tokens: Bob gains 250 USDC and pays his 1 TSLAx, and Alice gains 1 TSLAx and is left with 750 USDC. It then asserts the vault and offer accounts are gone, and that Alice's lamports round-tripped exactly, and so checks that no rent ended up with the wrong party. - `test_take_offer_rejects_switched_offer` and `test_take_offer_rejects_switched_offer_wanting_more_token_b` cancel and remake the offer between Bob building his take and sending it. Each asserts the take fails with `OfferTermsChanged`, Bob still holds his 1,000 USDC and 1 TSLAx, Alice received no TSLAx, and the switched offer's USDC is still in the vault. - `test_cancel_offer` asserts Alice's token A balance returns to its pre-offer value and her rent comes back in full. -- `test_cancel_offer_rejects_non_maker` is the adversarial case: Bob attempts to cancel Alice's offer and the transaction fails. Test the code path the bug lives in, and start with the one where tokens go to the wrong person. +- `test_cancel_offer_rejects_non_maker` is the adversarial case: Bob attempts to cancel Alice's offer and the transaction fails. -The testing section of the *Operating Financial Software* chapter builds the general method. The `kani-proofs` crate in the companion repository also model-checks the conservation invariants over every possible value, with harnesses like `proof_take_offer_conserves_value`, `proof_take_offer_honors_taker_terms`, and `proof_cancel_offer_returns_all_to_maker`. The crate models the native version's transfer-and-close arithmetic as pure functions, as the model-checking section of the *Operating Financial Software* chapter explains. +The testing section of the *Operating Financial Software* chapter builds the general method. The `kani-proofs` crate in the companion repository also model-checks the escrow's invariants over every possible value, with harnesses like `proof_take_offer_conserves_value`, `proof_take_offer_honors_taker_terms`, and `proof_cancel_offer_returns_all_to_maker`. The crate models the native version's transfer-and-close arithmetic as pure functions, as the model-checking section of the *Operating Financial Software* chapter explains. ## What You Learnt from Building the Escrow @@ -147,4 +147,4 @@ The escrow gave you four concepts: - **State transitions checked against signatures and stored terms.** Who signed, and what the stored data says, decide what may happen next; stored-address checks, seeds, and ATA derivation close every substitution. - **Every state has a handler that empties the vault.** `take_offer()` and `cancel_offer()` both empty and close everything, returning the tokens and the account rent deposits to whoever they belong to, so neither is stranded. -In the *Fundraiser* chapter we will build an escrow with many depositors instead of one maker. Contributions collect in one vault: the maker takes them the moment the target is reached, and if the deadline passes first, every contributor takes theirs back. Most programs in the rest of this part keep this chapter's shape (a vault whose authority is a PDA, and a handler in every state that empties it) and change only what decides who is paid. +In the *Fundraiser* chapter we will build an escrow with many depositors instead of one maker. Contributions collect in one vault: the maker can claim them once the target is reached, and if the deadline passes first, every contributor takes theirs back. Most programs in the rest of this part keep this chapter's shape (a vault whose authority is a PDA, and a handler in every state that empties it) and change only what decides who is paid. diff --git a/building-financial-products-in-solana/figures/amm-first-deposit.svg b/building-financial-products-in-solana/figures/amm-first-deposit.svg index c9af57ad..fa9d2aff 100644 --- a/building-financial-products-in-solana/figures/amm-first-deposit.svg +++ b/building-financial-products-in-solana/figures/amm-first-deposit.svg @@ -122,12 +122,16 @@ swap_tokens() claim_admin_fees() - + "CONFIG" - - - config + + + config + + admin: Maria + fee: 30 bps + admin_share_bps: 1,667 diff --git a/building-financial-products-in-solana/figures/betting-alice-claim.svg b/building-financial-products-in-solana/figures/betting-alice-claim.svg index f4603fb4..cb23a63d 100644 --- a/building-financial-products-in-solana/figures/betting-alice-claim.svg +++ b/building-financial-products-in-solana/figures/betting-alice-claim.svg @@ -28,8 +28,8 @@ "BET" + - OUTCOME ADDRESS + - BETTOR ADDRESS + GLOWBUGS 3 OUTCOME + + ALICE'S ADDRESS Alice's bet @@ -60,8 +60,8 @@ "BET" + - OUTCOME ADDRESS + - BETTOR ADDRESS + GLOWBUGS 3 OUTCOME + + BOB'S ADDRESS Bob's bet @@ -87,8 +87,8 @@ "BET" + - OUTCOME ADDRESS + - BETTOR ADDRESS + QUIET FLOOR OUTCOME + + CAROL'S ADDRESS Carol's bet @@ -143,10 +143,10 @@ event - status: Settled - winning_outcome_index: 0 - winning_pool: 400 - distributable_losing_pool: 196 + winning_outcome_index: 0 + winning_pool: 400 + distributable_losing_pool: 196 + open_bets: 2 @@ -183,7 +183,7 @@ rent refund 2. bet account closed - 300 stake + floor(300 × 196 ÷ 400) = 300 + 147 = 447; the 149 left is Bob's exact claim + the 149 USDC left is Bob's exact claim diff --git a/building-financial-products-in-solana/figures/betting-place-bets.svg b/building-financial-products-in-solana/figures/betting-place-bets.svg index b61edeec..ec9ee038 100644 --- a/building-financial-products-in-solana/figures/betting-place-bets.svg +++ b/building-financial-products-in-solana/figures/betting-place-bets.svg @@ -28,8 +28,8 @@ "BET" + - OUTCOME ADDRESS + - BETTOR ADDRESS + GLOWBUGS 3 OUTCOME + + ALICE'S ADDRESS Alice's bet @@ -62,8 +62,8 @@ "BET" + - OUTCOME ADDRESS + - BETTOR ADDRESS + GLOWBUGS 3 OUTCOME + + BOB'S ADDRESS Bob's bet @@ -96,8 +96,8 @@ "BET" + - OUTCOME ADDRESS + - BETTOR ADDRESS + QUIET FLOOR OUTCOME + + CAROL'S ADDRESS Carol's bet @@ -152,13 +152,14 @@ "EVENT" + EVENT ID - + event status: Open total_pool: 600 fee_bps: 200 + open_bets: 3 diff --git a/building-financial-products-in-solana/figures/betting-settlement.svg b/building-financial-products-in-solana/figures/betting-settlement.svg index f2784b01..40caa09c 100644 --- a/building-financial-products-in-solana/figures/betting-settlement.svg +++ b/building-financial-products-in-solana/figures/betting-settlement.svg @@ -25,8 +25,8 @@ "BET" + - OUTCOME ADDRESS + - BETTOR ADDRESS + GLOWBUGS 3 OUTCOME + + ALICE'S ADDRESS Alice's bet @@ -52,8 +52,8 @@ "BET" + - OUTCOME ADDRESS + - BETTOR ADDRESS + GLOWBUGS 3 OUTCOME + + BOB'S ADDRESS Bob's bet @@ -79,8 +79,8 @@ "BET" + - OUTCOME ADDRESS + - BETTOR ADDRESS + QUIET FLOOR OUTCOME + + CAROL'S ADDRESS Carol's bet @@ -181,9 +181,6 @@ 4 USDC (the fee) signed by the event PDA Maria signs settle_event(): Glowbugs 3 wins - losing pool: total_pool 600 − winning_pool 400 = 200 - fee: 2% of 200 = 4 (u128, rounded up) - distributable: 200 − 4 = 196 diff --git a/building-financial-products-in-solana/figures/crank-loop.svg b/building-financial-products-in-solana/figures/crank-loop.svg index 7e6e4431..d57b4532 100644 --- a/building-financial-products-in-solana/figures/crank-loop.svg +++ b/building-financial-products-in-solana/figures/crank-loop.svg @@ -6,10 +6,11 @@ - anyone's bot, - on a schedule + any client, + before its own + instruction - calls, every hour + calls first permissionless diff --git a/building-financial-products-in-solana/figures/escrow-make-offer.svg b/building-financial-products-in-solana/figures/escrow-make-offer.svg index 9b96ea35..ad794a34 100644 --- a/building-financial-products-in-solana/figures/escrow-make-offer.svg +++ b/building-financial-products-in-solana/figures/escrow-make-offer.svg @@ -112,7 +112,7 @@ make_offer() · 250 USDC (250,000,000 minor units of USDC; Alice signs) - Alice fronts the rent for the offer account, the vault, and her new TSLAx account + Alice pays the rent for the offer account, the vault, and her new TSLAx account diff --git a/building-financial-products-in-solana/figures/escrow-program-swap.svg b/building-financial-products-in-solana/figures/escrow-program-swap.svg index 847018ab..107971b0 100644 --- a/building-financial-products-in-solana/figures/escrow-program-swap.svg +++ b/building-financial-products-in-solana/figures/escrow-program-swap.svg @@ -31,7 +31,7 @@ 1 TSLAx (all of it, straight to Alice; the program never holds token B) - no percentage kept; the network fee is a fraction of a cent + no percentage kept diff --git a/building-financial-products-in-solana/figures/fund-create.svg b/building-financial-products-in-solana/figures/fund-create.svg index 44507fd0..c1da146d 100644 --- a/building-financial-products-in-solana/figures/fund-create.svg +++ b/building-financial-products-in-solana/figures/fund-create.svg @@ -245,7 +245,7 @@ AssetRate - Maria signs, and fronts the rent for + Maria signs, and pays the rent for the fund, its share mint, two asset configs and three vaults; no tokens move until an investor deposits diff --git a/building-financial-products-in-solana/figures/fund-fee-dilution.svg b/building-financial-products-in-solana/figures/fund-fee-dilution.svg index 208c6d0f..184367e4 100644 --- a/building-financial-products-in-solana/figures/fund-fee-dilution.svg +++ b/building-financial-products-in-solana/figures/fund-fee-dilution.svg @@ -1,5 +1,5 @@ - Maria claims her fees + The fee is minted to Maria diff --git a/building-financial-products-in-solana/figures/fund-withdraw-in-kind.svg b/building-financial-products-in-solana/figures/fund-withdraw-in-kind.svg index 668b4c28..23cb4f20 100644 --- a/building-financial-products-in-solana/figures/fund-withdraw-in-kind.svg +++ b/building-financial-products-in-solana/figures/fund-withdraw-in-kind.svg @@ -1,4 +1,4 @@ - + Alice withdraws in kind @@ -117,7 +117,6 @@ Alice's TSLAx ≈1.52 TSLAx - NEW @@ -129,7 +128,6 @@ Alice's NVDAx ≈2.85 NVDAx - NEW @@ -260,6 +258,7 @@ not drawn: the TSLAx, NVDAx and USDC mints, named only as seeds; the fund's rebalance_threshold_bps (200) and its recorded holdings, which equal the drawn vault balances. + Alice created her TSLAx and NVDAx accounts before the call. diff --git a/building-financial-products-in-solana/figures/fundraiser-refunds.svg b/building-financial-products-in-solana/figures/fundraiser-refunds.svg index c4561812..99e9440c 100644 --- a/building-financial-products-in-solana/figures/fundraiser-refunds.svg +++ b/building-financial-products-in-solana/figures/fundraiser-refunds.svg @@ -186,7 +186,7 @@ - day 7 has passed and the target was missed: 3,600 was raised, less refunds + 7 days have passed and the target was missed: 3,600 was raised, less refunds of 1,000, 800 and 1,000. Not drawn: Dave's wallet, USDC account and receipt, which still records 800 USDC diff --git a/building-financial-products-in-solana/figures/lending-open-market.svg b/building-financial-products-in-solana/figures/lending-open-market.svg index 5aeb5a7e..a98836a0 100644 --- a/building-financial-products-in-solana/figures/lending-open-market.svg +++ b/building-financial-products-in-solana/figures/lending-open-market.svg @@ -281,7 +281,7 @@ NEW mint authority: the reserve - Maria signs, and fronts the rent for the market, its price feeds and its reserves; + Maria signs, and pays the rent for the market, its price feeds and its reserves; no tokens move until a supplier deposits diff --git a/building-financial-products-in-solana/figures/lending-rate-curve.svg b/building-financial-products-in-solana/figures/lending-rate-curve.svg index 300bbb13..94a2231e 100644 --- a/building-financial-products-in-solana/figures/lending-rate-curve.svg +++ b/building-financial-products-in-solana/figures/lending-rate-curve.svg @@ -1,5 +1,5 @@ - The rate kinks at the target + The rate kinks at 80% utilization diff --git a/building-financial-products-in-solana/figures/options-open-venue.svg b/building-financial-products-in-solana/figures/options-open-venue.svg index 72acb5c8..8d4e5770 100644 --- a/building-financial-products-in-solana/figures/options-open-venue.svg +++ b/building-financial-products-in-solana/figures/options-open-venue.svg @@ -124,7 +124,7 @@ NEW authority: the market - Maria signs, and fronts the rent for the market and both vaults; + Maria signs, and pays the rent for the market and both vaults; no tokens move until a writer posts collateral diff --git a/building-financial-products-in-solana/figures/options-write-put.svg b/building-financial-products-in-solana/figures/options-write-put.svg index c0c52d2b..79a3d7cc 100644 --- a/building-financial-products-in-solana/figures/options-write-put.svg +++ b/building-financial-products-in-solana/figures/options-write-put.svg @@ -170,5 +170,5 @@ USDC mint - not drawn: Alice's and Bob's accounts, which the put does not touch + not drawn: Carol's new NVDAx account; Alice's and Bob's, which the put does not touch diff --git a/building-financial-products-in-solana/figures/order-book-critbit.svg b/building-financial-products-in-solana/figures/order-book-critbit.svg index 23aee870..54cf695b 100644 --- a/building-financial-products-in-solana/figures/order-book-critbit.svg +++ b/building-financial-products-in-solana/figures/order-book-critbit.svg @@ -36,13 +36,13 @@ Price: 1 (01) Sequence: 1 (01) - 1000 + 1010 Price: 2 (10) - Sequence: 0 (00) + Sequence: 2 (10) - 1100 + 1111 Price: 3 (11) - Sequence: 0 (00) + Sequence: 3 (11) 1st 2nd 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 c13dbef6..48edc753 100644 --- a/building-financial-products-in-solana/figures/order-book-settle.svg +++ b/building-financial-products-in-solana/figures/order-book-settle.svg @@ -249,14 +249,13 @@ 2 - 3 - - + Carol's own settle_funds() + 748.125 USDC + + 1 Pay Bob 3 NVDAx from the base vault 2 Pay Bob 250 USDC from the quote vault - 3 - Pay Carol 748.125 USDC from the quote vault not drawn: the NVDAx and USDC mints, which these accounts name only as seeds and fields diff --git a/building-financial-products-in-solana/figures/perps-counterparty.svg b/building-financial-products-in-solana/figures/perps-counterparty.svg index b7c018e3..b6d16883 100644 --- a/building-financial-products-in-solana/figures/perps-counterparty.svg +++ b/building-financial-products-in-solana/figures/perps-counterparty.svg @@ -1,4 +1,4 @@ - + The pool takes the other side @@ -33,8 +33,7 @@ collateral in, payout out - traders' losses and funding flow to the pool; traders' wins flow out of it - no order book, no matching: the pool takes the other side of both Alice and Bob + traders' losses and the heavier side's funding flow to the pool; wins and the lighter side's funding flow out diff --git a/building-financial-products-in-solana/figures/perps-create-pool.svg b/building-financial-products-in-solana/figures/perps-create-pool.svg index 39c0940b..2bdfbff7 100644 --- a/building-financial-products-in-solana/figures/perps-create-pool.svg +++ b/building-financial-products-in-solana/figures/perps-create-pool.svg @@ -162,7 +162,7 @@ owner: mock-price-feed - Maria signs, and fronts the rent for the pool, its vault and its share mint; + Maria signs, and pays the rent for the pool, its vault and its share mint; no tokens move until a liquidity provider deposits diff --git a/building-financial-products-in-solana/figures/prop-amm-alice-buy.svg b/building-financial-products-in-solana/figures/prop-amm-alice-buy.svg index 8bc25831..6a11137c 100644 --- a/building-financial-products-in-solana/figures/prop-amm-alice-buy.svg +++ b/building-financial-products-in-solana/figures/prop-amm-alice-buy.svg @@ -92,11 +92,12 @@ prop amm program - initialize_market() - set_quote() - deposit_inventory() - withdraw_inventory() - swap() + initialize_market() + set_quote() + deposit_inventory() + withdraw_inventory() + close_market() + swap() diff --git a/building-financial-products-in-solana/figures/prop-amm-bob-sell.svg b/building-financial-products-in-solana/figures/prop-amm-bob-sell.svg index e98fde48..59c99f31 100644 --- a/building-financial-products-in-solana/figures/prop-amm-bob-sell.svg +++ b/building-financial-products-in-solana/figures/prop-amm-bob-sell.svg @@ -91,11 +91,12 @@ prop amm program - initialize_market() - set_quote() - deposit_inventory() - withdraw_inventory() - swap() + initialize_market() + set_quote() + deposit_inventory() + withdraw_inventory() + close_market() + swap() diff --git a/building-financial-products-in-solana/figures/prop-amm-deposit-inventory.svg b/building-financial-products-in-solana/figures/prop-amm-deposit-inventory.svg index b8e95bb6..852d6a7a 100644 --- a/building-financial-products-in-solana/figures/prop-amm-deposit-inventory.svg +++ b/building-financial-products-in-solana/figures/prop-amm-deposit-inventory.svg @@ -82,11 +82,12 @@ prop amm program - initialize_market() - set_quote() - deposit_inventory() - withdraw_inventory() - swap() + initialize_market() + set_quote() + deposit_inventory() + withdraw_inventory() + close_market() + swap() diff --git a/building-financial-products-in-solana/figures/prop-amm-open-market.svg b/building-financial-products-in-solana/figures/prop-amm-open-market.svg index 11b93c6a..56f408ab 100644 --- a/building-financial-products-in-solana/figures/prop-amm-open-market.svg +++ b/building-financial-products-in-solana/figures/prop-amm-open-market.svg @@ -85,11 +85,12 @@ prop amm program - initialize_market() - set_quote() - deposit_inventory() - withdraw_inventory() - swap() + initialize_market() + set_quote() + deposit_inventory() + withdraw_inventory() + close_market() + swap() diff --git a/building-financial-products-in-solana/figures/prop-amm-set-quote.svg b/building-financial-products-in-solana/figures/prop-amm-set-quote.svg index 993cdc4c..7c765587 100644 --- a/building-financial-products-in-solana/figures/prop-amm-set-quote.svg +++ b/building-financial-products-in-solana/figures/prop-amm-set-quote.svg @@ -87,11 +87,12 @@ prop amm program - initialize_market() - set_quote() - deposit_inventory() - withdraw_inventory() - swap() + initialize_market() + set_quote() + deposit_inventory() + withdraw_inventory() + close_market() + swap() diff --git a/building-financial-products-in-solana/figures/prop-amm-withdraw-inventory.svg b/building-financial-products-in-solana/figures/prop-amm-withdraw-inventory.svg index b4689788..769fda38 100644 --- a/building-financial-products-in-solana/figures/prop-amm-withdraw-inventory.svg +++ b/building-financial-products-in-solana/figures/prop-amm-withdraw-inventory.svg @@ -91,11 +91,12 @@ prop amm program - initialize_market() - set_quote() - deposit_inventory() - withdraw_inventory() - swap() + initialize_market() + set_quote() + deposit_inventory() + withdraw_inventory() + close_market() + swap() diff --git a/building-financial-products-in-solana/fundraiser.md b/building-financial-products-in-solana/fundraiser.md index cdae6a86..e4322e0e 100644 --- a/building-financial-products-in-solana/fundraiser.md +++ b/building-financial-products-in-solana/fundraiser.md @@ -2,9 +2,9 @@ Maria has designed a compact commercial espresso machine, and the factory quoted 9,000 USDC for the first production run. The cost is indivisible: the injection-mold tooling has to be cut whole, so 6,000 USDC buys not two-thirds of a run but nothing at all. She could collect preorder promises by hand and hope enough of them are real, or she could put one rule onchain: **everyone's contribution comes back unless the full target amount is raised**. This chapter's program enforces that rule. It is an all-or-nothing fundraiser, the same promise Kickstarter makes, enforced by code instead of by a company. -The program lives at `finance/fundraiser` in the companion repository, with Anchor 1, Anchor 2, and Quasar versions; this chapter covers the Anchor 1 version, in `anchor-v1`. It is one of the smallest programs in this part of the book: six instruction handlers, two account types, and one vault. An escrow holds one stranger's tokens in a vault until the other stranger pays for them; a fundraiser holds many strangers' contributions in one vault until one shared condition is met. The vault and the PDA that signs for it work the same way, with one addition: a **deadline**. +The program lives at `finance/fundraiser` in the companion repository, with Anchor 1, Anchor 2, and Quasar versions; this chapter covers the Anchor 1 version, in `anchor-v1`. It is one of the smallest programs in this part of the book: six instruction handlers, two account types, and one vault. An escrow holds one stranger's tokens in a vault until the other stranger pays for them; a fundraiser holds many strangers' contributions in one vault until one shared condition is met, and adds a **deadline**. -The program calls the fundraiser's creator the **maker**, Maria here. She wants all-or-nothing. There is no platform operator and no fee in this program; the maker runs their own raise, and the only costs anyone pays are transaction fees, plus rent deposits that come back when the accounts close. +The program calls the fundraiser's creator the **maker**, Maria here. There is no platform operator and no fee in this program; the maker runs their own raise, and the only costs anyone pays are transaction fees, plus rent deposits that come back when the accounts close. ## The Maker Starts the Fundraiser @@ -25,7 +25,7 @@ The **fundraiser** account lives at a PDA with seeds `"fundraiser"` and the make The **vault** is an associated token account whose authority is the fundraiser PDA, the arrangement the *Escrow* chapter's vault used: tokens sit in an account only the program can sign for. -The handler stamps `time_started` from the cluster clock and stores the duration. `contribute()`, `refund()` and `close_fundraiser()` each recompute elapsed days from those two fields and compare the count with the duration, as the *Time* chapter explains, so the window closes exactly 604,800 seconds after `time_started`. +`contribute()`, `refund()` and `close_fundraiser()` each recompute elapsed days from those two fields and compare the count with the duration, as the *Time* chapter explains, so the window closes exactly 604,800 seconds after `time_started`. ## Every Backer Gets a Receipt @@ -37,7 +37,7 @@ Alice contributes her whole 1,000 USDC. `contribute()` moves the tokens from Ali The transaction also created an account for her. Each backer gets a **contribution** account at a PDA with seeds `"contribution"`, the fundraiser, and their own address, holding `amount`, their cumulative contribution, and the stored bump. It is a receipt the program writes for itself. A backer's first `contribute()` creates it and adds one to the fundraiser's `open_contributions`; every later contribution adds to the same receipt. Carol contributes 500 USDC, then comes back the next day with 500 more, and her receipt's `amount` reaches 1,000 while the fundraiser still counts one receipt for her. Anything under one major unit fails with `ContributionTooSmall`, so nobody fills the raise with dust. -So the contributions arrive like this: Alice 1,000, Bob 800, Carol 500 and 500, and eight more backers giving 6,200 between them. Total: exactly 9,000 USDC, from eleven receipts. +So the contributions arrive like this: Alice 1,000, Bob 800, Carol 500 and 500, and eight more backers giving 6,200 between them. They total 9,000 USDC, on eleven receipts. The program sets no limit on what one backer may give, and does not stop Maria contributing to her own raise (see the boxout *One person, many wallets* below). What the program guarantees is narrower: Maria receives the vault only when the counter shows the full 9,000 USDC, so she never receives a partial raise. Whether she then builds the machine is outside the program, as it is on Kickstarter. @@ -45,15 +45,15 @@ The program sets no limit on what one backer may give, and does not stop Maria c ## The Claim Reads the Program's Counter, Not the Vault Balance -The claim handler compares the target against `current_amount`, the program's own count of accepted contributions, and **never against the vault's token balance**. Anyone can transfer tokens to any token account on Solana, no permission required, and a transfer straight into the vault moves the balance without moving the counter. +The claim handler, `check_contributions()`, compares the target against `current_amount`, the program's own count of accepted contributions, and **never against the vault's token balance**. Anyone can transfer tokens to any token account on Solana, no permission required, and a transfer straight into the vault moves the balance without moving the counter. -`refund()` decides that a raise failed by reading `current_amount`, so the claim has to read the same number. If the claim read the balance instead, a transfer into the vault could make both handlers accept at once. Suppose 3,600 USDC had come in through `contribute()` and someone transferred 5,400 more straight into the vault. The balance would read 9,000, so the claim would pay all of it to Maria. The counter would still read 3,600, so `refund()` would promise each backer their contribution back, out of a vault that was now empty. +`refund()`, which repays backers after a missed deadline, decides that a raise failed by reading `current_amount`, so the claim has to read the same number. If the claim read the balance instead, a transfer into the vault could make both handlers accept at once. Suppose 3,600 USDC had come in through `contribute()` and someone transferred 5,400 more straight into the vault. The balance would read 9,000, so the claim would pay all of it to Maria. The counter would still read 3,600, so `refund()` would promise each backer their contribution back, out of a vault that was now empty. Reading the counter keeps the two endings exclusive. The test `test_check_contributions_ignores_direct_vault_donations` mints the full target straight into the vault and asserts that the claim still fails with `TargetNotMet`. Apply the same check to any handler that releases tokens from a vault: compare a number the program itself wrote, never a token balance anyone can raise by transferring tokens in. ## The Target Is Met: The Maker Claims -`check_contributions()` is the maker's claim. Maria signs, the PDA seeds re-derive her fundraiser, and `current_amount` has reached the target, so the handler does not fail with `TargetNotMet`. It sets `claimed` and transfers the vault's entire balance to Maria's token account, signed by the fundraiser PDA. A second claim fails with `FundraiserClaimed`, and so does a contribution sent after the claim, even with days left before the deadline. The test `test_check_contributions_success_pays_maker_and_marks_claimed` asserts the payment and the flag. +`check_contributions()` is the maker's claim. Maria signs, the PDA seeds re-derive her fundraiser, and `current_amount` has reached the target, so the handler sets `claimed` and transfers the vault's entire balance to Maria's token account, signed by the fundraiser PDA. Below the target it fails with `TargetNotMet`. A second claim fails with `FundraiserClaimed`, and so does a contribution sent after the claim, even with days left before the deadline. The test `test_check_contributions_success_pays_maker_and_marks_claimed` asserts the payment and the flag. ![](figures/fundraiser-success.svg) @@ -71,7 +71,7 @@ With the counter at zero, Maria calls `close_fundraiser()`. It sends her any tok ![](figures/fundraiser-close.svg) -The test `test_stale_contribution_cannot_refund_from_next_raise` runs the attack in full. A raise succeeds, its receipts and the fundraiser are closed, and Maria starts a second raise at the same address, which then fails. A first-raise backer's refund from the second raise fails, because their receipt no longer exists, and every second-raise backer is refunded exactly what they put in. `test_reinitialize_with_open_contributions_fails` checks that a second `initialize_fundraiser()` fails while the claimed fundraiser, and so its receipts, still exist. +The test `test_stale_contribution_cannot_refund_from_next_raise` runs the attack in full. A raise succeeds, its receipts and the fundraiser are closed, and Maria starts a second raise at the same address, which then fails. A first-raise backer's refund from the second raise fails because their receipt no longer exists: Anchor refuses the empty address with `AccountNotInitialized` before the handler runs. Every second-raise backer is refunded exactly what they put in. `test_reinitialize_with_open_contributions_fails` claims a raise, leaves its receipts open, and checks that a second `initialize_fundraiser()` fails with the System Program's `AccountAlreadyInUse`, because the claimed fundraiser still holds the address. ## The Target Is Missed: Everyone Leaves With Exactly What They Brought @@ -83,7 +83,7 @@ A second refund fails because the receipt it would read has been closed, the *St Refunds have no deadline. A backer usually sends their own, but `refund()` does not need their signature: the tokens can only go to the backer's token account and the rent to the backer, whoever sends the transaction. `test_anyone_can_refund_a_contributor` checks that a refund Maria sends still pays the backer. -Dave has not come back for his 800 USDC, so Maria sends his refund herself. With every contribution refunded she calls `close_fundraiser()`, which on an unclaimed fundraiser checks the deadline has passed, the target was missed, and the counter is back to zero (`RefundsOutstanding` otherwise, so she cannot close the vault while any backer is still owed). It then closes the vault and the fundraiser as the figure after the claim shows, with `claimed` still false and `current_amount` at zero. Nothing of the failed raise remains onchain, and Maria can try again next quarter with a smaller target. Every account this program creates for itself is closed on both paths: the receipts close through `refund()` here and through `close_contribution()` after a claim. +Dave has not come back for his 800 USDC, so Maria sends his refund herself. With every contribution refunded she calls `close_fundraiser()`, which on an unclaimed fundraiser checks the deadline has passed, the target was missed, and the counter is back to zero (`RefundsOutstanding` otherwise, so she cannot close the vault while any backer is still owed). It then closes the vault and the fundraiser and returns both rent deposits to Maria, as `close_fundraiser()` does after a claim, but with `claimed` still false and `current_amount` at zero. Nothing of the failed raise remains onchain, and Maria can try again next quarter with a smaller target. Every account this program creates for itself is closed on both paths: the receipts close through `refund()` here and through `close_contribution()` after a claim. ## The Math: Two Minimums and a Day Count @@ -93,7 +93,7 @@ The program's financial arithmetic is three expressions: - **The minimum contribution**: `10^decimals`. - **Elapsed days**: the *Time* chapter's whole-day count, compared with `duration`. -Every addition and subtraction on `current_amount`, the receipts and `open_contributions` is checked and maps overflow to `MathOverflow`. No rates, no proportional shares, no prices: refunds pay recorded amounts back verbatim, so, like the escrow, this program never has to *compute* what people are owed. +Every addition and subtraction on `current_amount`, the receipts and `open_contributions` is checked and maps overflow to `MathOverflow`. No rates, no proportional shares, no prices: refunds pay back the recorded amounts unchanged, so, like the escrow, this program never has to *compute* what people are owed. ## The Invariants: The Counter Equals the Sum of the Receipts @@ -106,12 +106,12 @@ Every addition and subtraction on `current_amount`, the receipts and `open_contr ## The Failure Modes: One Check per Attack -- **Claiming early or after a miss** fails on the counter: `TargetNotMet`. +- **Claiming before the counter reaches the target, or after a miss,** fails with `TargetNotMet`. - **Claiming twice, or contributing after the claim,** fails with `FundraiserClaimed` (checked by `test_second_claim_fails` and `test_contribute_after_claim_fails`). - **Refunding early or after a win** fails on the deadline or the counter: `FundraiserNotEnded`, `TargetMet` (checked by `test_refund_before_deadline_fails` and `test_refund_when_target_met_fails`). - **Contributing after the deadline** fails with `FundraiserEnded` (checked by `test_contribute_after_deadline_fails`). - **Transferring tokens straight into the vault** leaves the claim failing with `TargetNotMet`. -- **Reusing a receipt in the next raise at the same address** fails because the fundraiser cannot close while a receipt is open: `ContributionsOpen` (checked by `test_close_fundraiser_with_open_contributions_fails` and `test_stale_contribution_cannot_refund_from_next_raise`). +- **Reusing a receipt in the next raise at the same address** fails because the fundraiser cannot close while a receipt is open: `ContributionsOpen` (checked by `test_close_fundraiser_with_open_contributions_fails`). - **Double refunds** fail because the receipt account is gone. - **Dust contributions** fail on the one-major-unit minimum: `ContributionTooSmall`. - **Closing the fundraiser early or with contributions inside** fails with `FundraiserNotEnded` before the deadline, `TargetMet` on a raise that met its target but is unclaimed, and `RefundsOutstanding` while any contribution remains unrefunded. @@ -119,7 +119,7 @@ Every addition and subtraction on `current_amount`, the receipts and `open_contr ## Testing Considerations -The suite warps the clock with a helper that advances whole days, and asserts balances and the specific error after every move. Four tests follow a whole lifecycle: +The suite warps the clock with a helper that advances whole days, checks balances, and asserts the specific error on every refusal. Four tests follow a whole lifecycle: - `test_check_contributions_success_pays_maker_and_marks_claimed` funds the raise with three unequal contributions and claims it. - `test_close_fundraiser_after_claim_allows_a_new_raise` closes every receipt and the fundraiser after a claim, and checks the same wallet can raise again. @@ -130,17 +130,17 @@ Most of the remaining tests call a handler in a state where it must refuse, and ## What You Learnt from Building the Fundraiser -Kickstarter's all-or-nothing promise is a company policy: the platform collects the pledges and charges a fee, and its rules are whatever its terms say this month. This chapter's program enforces the same all-or-nothing outcome in code: no platform, no fee, and terms that cannot change because no handler exists to change them. +Kickstarter's all-or-nothing promise is a company policy, set in terms the company can change. This chapter's program enforces the same all-or-nothing outcome in code: no platform, no fee, and terms that no instruction handler can change. The fundraiser added four concepts on top of the escrow's: - **Checks read the program's counter, not the vault balance.** - **A receipt account per participant**, at a PDA, created on first use, added to on later contributions, and closed on refund, so a second refund finds nothing to read. -- **An account outlives every account derived from its address.** Count the derived accounts, and refuse to close the parent until the count is zero, so a new account at the same address never inherits the old one's receipts. +- **Keep an account open while any account derived from its address exists.** Count the derived accounts, and refuse to close the parent until the count is zero, so a new account at the same address never inherits the old one's receipts. - **Every account has a handler that closes it.** A successful raise leaves the receipts open, so a handler exists to close each one, and anyone may send it because the rent can only go to its backer. In the *Betting Market* chapter we will build a pool that many people pay into, where who gets paid is decided by something the program cannot see: which outcome happened in the real world. Someone has to tell the program the answer, and the program limits what that person can do with the pooled stakes while trusting them for the result. -[^any-mint]: Any mint works, Token Extensions mints included, because every account is declared through the token interface. +[^any-mint]: The program's tests use Classic Token Program mints. [^personhood-services]: VeryAI is at [very.org](https://very.org), and World ID at [world.org](https://world.org). diff --git a/building-financial-products-in-solana/lending.md b/building-financial-products-in-solana/lending.md index 01046014..8513dec1 100644 --- a/building-financial-products-in-solana/lending.md +++ b/building-financial-products-in-solana/lending.md @@ -1,8 +1,8 @@ # Chapter 17: Lending and Borrowing -Start with a commercial bank. Savers make deposits and earn interest. Borrowers take loans and pay interest. The bank sits in the middle, collecting from the borrowers, paying most of it to the savers, and keeping a slice for itself. The onchain equivalent is a borrow-lend market, usually just called a **lending market**: Kamino Lend, Jupiter Lend, and Save are lending markets on Solana, and lending is one of the largest categories of capital on the network.[^lending-size] The program for this chapter lives at `finance/lending`, and it is the largest program in the book so far: interest that compounds on every refresh, collateral that gets marked to market, and a liquidation engine that keeps the market solvent when prices move. +Start with a commercial bank. Savers make deposits and earn interest. Borrowers take loans and pay interest. The bank sits in the middle, collecting from the borrowers, paying most of it to the savers, and keeping a slice for itself. The onchain equivalent is a borrow-lend market, usually just called a **lending market**: Kamino Lend, Jupiter Lend, and Save are lending markets on Solana, and lending is one of the largest categories of capital on the network.[^lending-size] The program for this chapter lives at `finance/lending`, and it has the most instruction handlers of any program in the book so far. It has interest that compounds on every refresh, collateral that gets marked to market, and a liquidation engine that keeps the market solvent when prices move. -One word is overloaded. This chapter's market trades stocks like NVDAx and SPCXx, which people colloquially call "shares." **In this chapter, a share is a share of a reserve**: a token minted by the program representing your claim on a pool of deposited assets, including the interest it earns. The stocks are just tokens. +One word is overloaded. This chapter's market trades stocks like NVDAx and SPCXx, which people colloquially call "shares." **In this chapter, a share is a share of a reserve**: a token minted by the program representing your claim on a pool of deposited assets, including the interest it earns. The stocks are tokens like any other. ## Prices Come In by Signature: The Price Feed, Freshness, and Slots @@ -18,11 +18,11 @@ Maria runs the market, and her revenue is a cut of the interest, the reserve fac ![](figures/lending-open-market.svg) -Opening the market created thirteen accounts: the market, and for each asset a price feed, a reserve, and the two accounts the reserve owns. +Opening the market created thirteen accounts: the market, and for each asset a price feed, a reserve, and the two accounts whose authority is the reserve. The **lending market** account (PDA seeds: `"lending_market"`, a market ID) stores the `owner` (Maria, used only for authorization, never in the address) and the `quote_currency_mint`, the stable asset all values are measured in: USDC. Distinct market IDs give independent, risk-isolated pools. -Each asset gets a **reserve** account (PDA seeds: `"reserve"`, the market, the asset's mint). The reserve's PDA is the authority of two accounts: a **liquidity vault** holding deposited tokens, and a **share mint** whose tokens are suppliers' claims on the vault plus its accrued interest. The reserve's five running totals are `available_liquidity`, `share_mint_supply`, the `borrowed_principal` of all its loans, the `borrow_accumulation_factor` that makes interest work (below), and `accumulated_program_fees`, Maria's slice, carved out so it never inflates the suppliers' exchange rate. `available_liquidity` is the un-borrowed pool, and the program prices shares from it instead of from the vault's raw balance, so donating tokens to the vault cannot move the exchange rate. +Each asset gets a **reserve** account (PDA seeds: `"reserve"`, the market, the asset's mint). The reserve's PDA is the authority of two accounts: a **liquidity vault** holding deposited tokens, and a **share mint** whose tokens are suppliers' claims on the vault plus its accrued interest. The reserve's five running figures are `available_liquidity`, `share_mint_supply`, the `borrowed_principal` of all its loans, the `borrow_accumulation_factor` that every debt is multiplied by (the interest section explains it), and `accumulated_program_fees`, Maria's slice, carved out so it never inflates the suppliers' exchange rate. `available_liquidity` is the un-borrowed pool, and the program prices shares from it instead of from the vault's raw balance, so donating tokens to the vault cannot move the exchange rate. Each reserve carries a `ReserveConfig`, all in basis points, and its validation rules are the credit-risk controls: @@ -38,11 +38,11 @@ Each reserve carries a `ReserveConfig`, all in basis points, and its validation ## Supplying: Tokens In, Shares Out -Before anyone supplies, Maria makes the first deposit into each reserve herself, running `deposit_reserve_liquidity()` three times with 1,001 minor units of USDC, NVDAx and SPCXx (she bought that much of each stock beforehand for the purpose). A reserve's share token has as many decimals as its asset, and the first deposit mints one minor unit of shares per minor unit deposited, less 1,000 that are withheld and never minted: the program's `MINIMUM_SHARES`. So Maria holds one minor unit of each reserve's shares, and the 1,000 withheld units belong to nobody. +Before anyone supplies, Maria makes the first deposit into each reserve herself, running `deposit_reserve_liquidity()` three times with 1,001 minor units of USDC, NVDAx and SPCXx (the NVDAx and SPCXx come from outside her standard wallet). A reserve's share token has as many decimals as its asset, and the first deposit mints one minor unit of shares per minor unit deposited, less 1,000 that are withheld and never minted: the program's `MINIMUM_SHARES`. So Maria holds one minor unit of each reserve's shares, and the 1,000 withheld units belong to nobody. ![](figures/lending-open-reserves.svg) -Alice has her standard 1,000 USDC, plus four SPCXx (SpaceX stock, $250 each), sitting idle, and she wants yield. She runs `deposit_reserve_liquidity()` with her 1,000 USDC. Every deposit after a reserve's first mints by the going rate: shares = amount × (share supply + 1,000) ÷ total liquidity, floored, where total liquidity is available plus borrowed (with accrued interest) minus the house's accumulated fees. The withheld 1,000 count in every conversion between shares and liquidity, the defense the *Constant-Product AMM* chapter builds, and the failure-modes section shows the attack it stops here. The USDC reserve holds 1,001 minor units against 1,001 minor units of shares, one to one, so Alice receives 1,000 USDC shares; she does the same with her four SPCXx. The share price is how yield arrives: interest grows the pool while the share supply is unchanged, so each share redeems for more than it did. The program keeps no per-lender interest records. +Alice has her standard 1,000 USDC, plus four SPCXx (SpaceX stock, $250 each), sitting idle, and she wants yield. She runs `deposit_reserve_liquidity()` with her 1,000 USDC. Every deposit after a reserve's first mints by the going rate: shares = amount × (share supply + 1,000) ÷ total liquidity, floored, where total liquidity is available plus borrowed (with accrued interest) minus the house's accumulated fees. The withheld 1,000 count in every conversion between shares and liquidity, the defense the *Constant-Product AMM* chapter builds, and the failure-modes section shows how, without them, a lone supplier who borrows from their own reserve could inflate its shares. The USDC reserve holds 1,001 minor units against 1,001 minor units of shares, one to one, so Alice receives 1,000 USDC shares; she does the same with her four SPCXx. The share price is how yield arrives: interest grows the pool while the share supply is unchanged, so each share redeems for more than it did. The program keeps no per-lender interest records. ![](figures/lending-deposit-shares.svg) @@ -55,7 +55,7 @@ Bob needs cash but does not want to sell his five NVDAx (NVIDIA stock, $200 each 3. `deposit_obligation_collateral()`: moves his five NVDAx shares into a collateral vault owned by his obligation. What he pledges is *shares*, not raw tokens, so his collateral keeps earning supplier yield while it is pledged. 4. `borrow_obligation_liquidity()`: with his collateral worth $1,000 and loan-to-value at 75%, he may borrow up to $750 of USDC, which he does, drawn from the USDC reserve's available liquidity. -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. +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 once 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 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`. @@ -69,7 +69,7 @@ Carol thinks SpaceX is overvalued and wants to profit if it falls. Her trade is ![](figures/lending-short-collateral.svg) -If SpaceX falls, she buys the three tokens back cheaper and keeps the difference. If SpaceX rises, her debt rises with it, against collateral that does not. She has borrowed at the edge of her allowance with a debt that moves. +If SpaceX falls, she buys the three tokens back cheaper and keeps the difference. If SpaceX rises, her debt rises with it, against collateral that does not. Above about $266.67 an SPCXx, her debt passes $800, 80% of her collateral, and she can be liquidated. ![](figures/lending-short-borrow.svg) @@ -77,7 +77,7 @@ If SpaceX falls, she buys the three tokens back cheaper and keeps the difference Weeks pass, and the loans accrue interest without a single token moving. The program tracks that interest with the **borrow accumulation factor**. -It is one number per reserve, and it only grows. The program writes 1.0 as 10^18, a scale chosen so a single second's interest does not truncate to zero while staying far below u128's maximum, so the math needs no fixed-point crates: just u128, the scale constant, and two rounding helpers. On every `refresh_reserve()`, the program computes the current borrow rate, multiplies it by the seconds elapsed, and grows the factor: new factor = old factor × (1 + rate per second × elapsed seconds). Each obligation stores its borrow as `borrowed_principal`: the amount borrowed divided by the factor at the moment of borrowing, rounded up. A live debt is that principal times the current factor, rounded *up*, because debt rounds against the debtor. No loan is ever visited individually. Tokens move only when people repay or redeem, like the unsettled balances in the *Order Book Exchange* chapter, but across time instead of across fills. +It is one number per reserve, and it only grows. The program writes 1.0 as 10^18. At that scale a single second's interest does not truncate to zero, and the factor stays far below u128's maximum. The math uses only u128, the scale constant, and two rounding helpers. On every `refresh_reserve()`, the program computes the current borrow rate, multiplies it by the seconds elapsed, and grows the factor: new factor = old factor × (1 + rate per second × elapsed seconds). The utilization, the rate and the new factor each round up, so interest rounds against the borrower (`accumulation_factor_rounds_up_against_the_borrower`). Each obligation stores its borrow as `borrowed_principal`: the amount borrowed divided by the factor at the moment of borrowing, rounded up. A live debt is that principal times the current factor, rounded *up*, because debt rounds against the debtor. No loan is ever visited individually. Interest moves no tokens until a borrower repays or a supplier redeems, like the unsettled balances in the *Order Book Exchange* chapter, but across time instead of across fills. The rate itself comes from a **kinked utilization curve**. Utilization is borrowed ÷ (available + borrowed). Below the kink at 80% utilization, the rate climbs gently from 2% toward 20% APR; above it, steeply toward 150%. Above the kink, when nearly everything is lent out, the rate spikes, which pushes borrowers to repay and pulls new suppliers in with yield. @@ -96,9 +96,9 @@ The other thing that moves over time is prices. SPCXx rallies from $250 to $300, Dave watches for liquidation opportunities. He buys 1.5 SPCXx for $450 of his USDC and runs `liquidate_obligation()`, repaying that much of Carol's SPCXx debt in exchange for her USDC collateral at a 5% bonus. The handler enforces four rules: - A healthy obligation cannot be touched: the handler rejects it with `ObligationHealthy` (checked by `healthy_obligation_cannot_be_liquidated`). A liquidator chooses only among positions already past the liquidation threshold. -- The **close factor** caps one liquidation at 50% of the debt. Dave repays 1.5 SPCXx ($450). +- The **close factor** caps one liquidation at 50% of the debt, and a repayment above it is reduced to it. Dave repays 1.5 SPCXx ($450). - The **bonus** prices his service: he seizes $450 × 1.05 = $472.50 of Carol's collateral, paid in USDC shares, every step of the conversion floored toward the borrower. -- Overreach fails the whole call: asking to seize more collateral than the obligation holds rejects with `LiquidationTooLarge` rather than silently capping, because a silent cap would make the liquidator pay full price for less collateral (checked by `over_seizing_liquidation_rejected_smaller_succeeds`). +- A seizure larger than the collateral is different: it fails the whole call with `LiquidationTooLarge` rather than being silently capped, because a silent cap would make the liquidator pay full price for less collateral (checked by `over_seizing_liquidation_rejected_smaller_succeeds`). Carol is still slightly unhealthy, so Dave buys another 0.75 SPCXx for $225 and goes again: half of the remaining 1.5 SPCXx debt, seizing $236.25. Her loan is healthy again, still hers, partially paid down by her own collateral. Across both passes Dave repaid $675 of debt and collected $708.75, a $33.75 profit: 5% for keeping the market solvent. @@ -108,19 +108,18 @@ Carol is still slightly unhealthy, so Dave buys another 0.75 SPCXx for $225 and ![](figures/lending-liquidation-health.svg) -## Cranks: Work With No Natural Caller +## Cranks: Maintenance Anyone May Run -Some work in a financial product has no natural caller: interest must accrue, cached valuations must refresh, and no user's own transaction is obliged to do any of it. Onchain, that work goes to a **crank**: an instruction handler that anyone may call, safe to call at any time, so that keeping the market's state current is a permissionless job rather than one only the operator can do. A good crank has three properties: +Interest must accrue and cached valuations must refresh before a handler can rely on them. That work goes to a **crank**: an instruction handler that anyone may call, safe to call at any time, so that keeping the market's state current is a permissionless job rather than one only the operator can do. This program's cranks are `refresh_reserve()` and `refresh_obligation()`. A handler that values a reserve or judges an obligation refuses a stale one, so each user's transaction runs the refreshes first, before its own instruction. A reserve nobody touches accrues nothing until then, and the next refresh charges all the seconds since the last one. A good crank has two properties: -- **Permissionless: the handler checks state, not identity.** If the work is valid, anyone's signature will do. `refresh_reserve()` and `refresh_obligation()` are the canonical examples: they bring interest and cached valuations up to date for whoever asks, no special signer required, and requiring the operator's signature for maintenance would turn the operator's absence into everyone's outage. Anyone can run the crank, so a crank that moves funds must calculate their destination from information the program itself stores in its own accounts, never by paying the signer: a crank that pays whoever called has made its funds claimable by strangers. -- **Idempotent: a no-op succeeds.** Calling the crank when there is nothing to do succeeds cleanly (refreshing an already-fresh reserve rewrites the same numbers), so a bot on a schedule never needs attention and never fails spuriously. -- **Paid, if you need it reliable.** Dave is paid, but not as a crank operator: he acts on his own account, repaying someone else's debt with his own funds and taking their collateral at a discount. He risks his own capital on prices holding while he does it. +- **Permissionless: the handler checks state, not identity.** If the work is valid, anyone's signature will do. Both refreshes bring interest and cached valuations up to date for whoever asks, no special signer required, and requiring the operator's signature for maintenance would turn the operator's absence into everyone's outage. Anyone can run the crank, so a crank that moves funds must calculate their destination from information the program itself stores in its own accounts, never by paying the signer: a crank that pays whoever called has made its funds claimable by strangers. +- **Idempotent: a no-op succeeds.** Calling the crank when there is nothing to do succeeds cleanly (refreshing an already-fresh reserve rewrites the same numbers), so a client can put it in every transaction and it never fails spuriously. ![](figures/crank-loop.svg) ## Repayment, Fees, and Yield: Everyone Settles Up -Five weeks in, Bob no longer needs the cash. His $750 debt has become about $757.50 through the factor. He runs `repay_obligation_liquidity()` (anyone may repay any obligation, so there is no owner check; over-repayment clamps to the debt, and any sub-unit of principal stays with the borrower rather than being forgiven by rounding). +Five weeks in, Bob no longer needs the cash. The USDC reserve's factor has grown to about 1.01, and the walkthrough and its figures take it as exactly 1.01, so his $750 debt has become $757.50. He runs `repay_obligation_liquidity()` (anyone may repay any obligation, so there is no owner check; over-repayment clamps to the debt, and any sub-unit of principal stays with the borrower rather than being forgiven by rounding). ![](figures/lending-repay.svg) @@ -128,7 +127,7 @@ Then `withdraw_obligation_collateral()` releases his NVDAx shares. Bob owes noth ![](figures/lending-withdraw-collateral.svg) -`redeem_reserve_collateral()` then turns the shares back into his five NVDAx. Total cost of five weeks' liquidity: about seven and a half dollars, and he never sold his NVIDIA. +`redeem_reserve_collateral()` then turns the shares back into his five NVDAx. ![](figures/lending-redeem-collateral.svg) @@ -145,12 +144,12 @@ The final balances are: - **Alice**: about $3.38 for five weeks of otherwise idle capital. - **Bob**: five weeks of liquidity for about $7.50, and he kept his stock. - **Carol**: $1,000 became about $816.25, most of it lost to the price moving against her short, $33.75 of it paid to Dave. -- **Dave**: $33.75 for watching. -- **Maria**: $0.75 on one small loan in one small market, a number that becomes a business on a large market. +- **Dave**: $33.75 for repaying $675 of Carol's debt. +- **Maria**: $0.75, her 10% of the interest on Bob's loan (Carol's loan is left out, as above). ## The Math: Two Rounding Helpers and a Rule -Every number in this program flows through `mul_div_floor()` or `mul_div_ceil()`, u128 in, multiply before divide, checked everywhere. The rule deciding which to use is the one the *Arithmetic of Money* chapter sets: quantities favorable to the user round down; debt rounds up, and so does the borrow power a withdrawal removes. +Every number in this program flows through `mul_div_floor()` or `mul_div_ceil()`, u128 in, multiply before divide, checked everywhere. The rule deciding which to use is the one the *Arithmetic of Money* chapter sets: quantities favorable to the user round down; debt and the interest that grows it round up, and so does the borrow power a withdrawal removes. Because every division rounds this way, the market never loses a minor unit to rounding. `deposit_redeem_round_trip_creates_no_value` runs fifty deposit-and-redeem round trips of 777,777,777 minor units against a reserve whose exchange rate a tenth of a year's interest has moved off one-to-one, and asserts after each that the supplier holds no more than they started with. @@ -173,7 +172,7 @@ Because every division rounds this way, the market never loses a minor unit to r ## 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, 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. +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 (`seizing_all_collateral_closes_the_vault_into_the_obligation`, `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 (the tests listed under failure modes). 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 @@ -183,9 +182,9 @@ Six concepts came out of the lending market: - **Shares as the yield mechanism.** Yield arrives as a rising share price, so no per-lender bookkeeping exists or needs to. - **The accumulation factor.** One growing index per reserve computes every debt; the same structure prices any claim on a growing pool. -- **Valuation that rounds against the borrower.** Collateral floors and debt ceils. +- **Valuation that rounds against the borrower.** Collateral floors, and debt and interest ceil. - **Liquidation as a paid correction.** The bonus pays the liquidator; `ObligationHealthy` rejects any position not already past the threshold. -- **Cranks.** Permissionless, idempotent maintenance that pays funds only to destinations read from stored state, and whose reliability comes from someone being paid. +- **Cranks.** Permissionless, idempotent maintenance that pays funds only to destinations read from stored state, run by each client before its own instruction. - **Refreshes in the same transaction.** No handler values anything it has not refreshed in the same slot. In the *Managed Fund* chapter we will build a fund: investors deposit into a pool of assets, and a manager trades that pool on their behalf. The lender in this chapter protects itself from its borrowers. The fund has to protect its investors from its own manager. diff --git a/building-financial-products-in-solana/managed-fund.md b/building-financial-products-in-solana/managed-fund.md index 236e8f02..b3a7bfac 100644 --- a/building-financial-products-in-solana/managed-fund.md +++ b/building-financial-products-in-solana/managed-fund.md @@ -1,8 +1,8 @@ # Chapter 18: The Managed Fund -A **managed fund** is the onchain equivalent of a mutual fund. A list of approved assets, a fund manager who picks the allocation, investors who deposit and receive shares, a management fee for the manager's expertise. Symmetry and Kamino run managed funds on Solana. The program lives at `finance/managed-fund` in the companion repository, and its story trades stocks: TSLAx and NVDAx, quoted in USDC. +A **managed fund** is the onchain equivalent of a mutual fund. It has a list of approved assets, a fund manager who picks the allocation, investors who deposit and receive shares, and a management fee for the manager. Symmetry and Kamino run managed funds on Solana. The program lives at `finance/managed-fund` in the companion repository, and its story trades stocks: TSLAx and NVDAx, quoted in USDC. -In a traditional fund, "the manager cannot run off with client assets" is enforced by custodians, auditors, and courts. Here it is enforced by the program: **the manager can change the allocation and earn the fee, and can do nothing else.** No instruction in the program moves a vault's tokens to the manager. +In a traditional fund, "the manager cannot run off with client assets" is enforced by custodians, auditors, and courts. Here it is enforced by the program: **the manager can change the allocation and earn the fee, and can do nothing else.** No instruction handler pays a vault's tokens to the manager, except what their fee shares redeem through `withdraw()`, the same as any holder's. > **Sometimes called a "vault".** Some Solana platforms sell this product as a "vault": Drift Vaults and Kamino's Earn vaults both pool deposits under a manager or curator. This book uses the finance name because every chapter already uses "vault" for a token account owned by a program. So **the product is the fund, and the token accounts are its vaults.** Nor is it an index fund, though the two look alike: an index fund's weights follow a published index with no manager choosing them, and here a manager chooses them. @@ -12,7 +12,7 @@ Two different people hold power in this program, and their separation is its fir 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`). +`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`). ![](figures/fund-registry.svg) @@ -30,15 +30,15 @@ Maria's three transactions created the seven accounts the fund needs. The **fund** account (PDA seeds: `"fund"`, an index) records the `manager`, the `registry` it is bound to, its `fee_bps`, `max_slippage_bps` and `rebalance_threshold_bps`, and the `swap_router` it is allowed to trade through. It keeps its running `total_shares` and `total_weight_bps`, the `usdc_decimals` of its USDC mint, and `last_fee_accrual_timestamp` for the fee clock. It also records `usdc_holdings` and `asset_holdings`, the amounts the program has counted into its vaults. The **share mint** lives at a PDA (seeds: `"share_mint"`, the fund) with the fund PDA as mint authority: shares only come into existence when the program's own math says so. The fund PDA is also the authority of a **USDC vault**, which every deposit passes through on its way into the basket. -Each basket asset gets an **asset config** account (PDA seeds: `"asset"`, the fund, an index byte) recording its mint and that mint's `decimals`, its price feed, and its weight in basis points. It also records its **vault**, a token account whose authority is the fund PDA. The fund holds up to `MAX_ASSETS` (16), and the cap is not about storage. A deposit has to reference every asset's accounts at once, five per asset, so a full basket runs to 94 accounts. That fits inside Solana's account-lock limit, but it is far past what a legacy transaction can hold, so clients of big baskets send v0 transactions with an address lookup table. +Each basket asset gets an **asset config** account (PDA seeds: `"asset"`, the fund, an index byte) recording its mint and that mint's `decimals`, its price feed, and its weight in basis points. It also records its **vault**, a token account whose authority is the fund PDA. The fund holds up to `MAX_ASSETS` (16), and the cap is not about storage. A deposit has to reference every asset's accounts at once, five per asset plus the 14 every deposit takes, so a full basket runs to 94 accounts. That fits inside Solana's account-lock limit, but it is far past what a legacy transaction can hold, so clients of big baskets send v0 transactions with an address lookup table. -Every handler that computes the fund's value walks indexes 0 through `asset_count` and refuses to run if any account is missing (`IncompleteAssetAccounts`), so a caller cannot leave an asset out and understate the fund's value. A retired asset (weight set to zero) keeps its index forever rather than being reused, so the contiguous range the valuation loop depends on stays intact. +Every handler that computes the fund's value walks every index below `asset_count` and refuses to run if any account is missing (`IncompleteAssetAccounts`), so a caller cannot leave an asset out and understate the fund's value. A retired asset (weight set to zero) keeps its index rather than being reused, so the contiguous range the valuation loop depends on stays intact. ## A Deposit Makes the Program Acquire Assets Alice invests from the standard wallet of one SOL and 1,000 USDC. She runs `deposit()` with 900 USDC and a `minimum_shares` floor. The handler values the fund and prices the shares before it takes the USDC in, so a deposit never counts toward its own price; it then deploys the USDC and mints the shares last. -**First it values the fund.** It walks every asset: reads the holding the program has recorded for that vault, loads the Pyth price, and accumulates value in u128, multiply before divide. A Pyth price is an integer and an exponent, so $250 arrives as 25,000,000,000 at exponent −8, or as 25,000,000 at exponent −5 on Pyth's US equity price feeds. A holding is in the asset's minor units, so 1.44 TSLAx is 144,000,000 at its eight decimals, and would be 1,440,000 at six. The handler reads the exponent from the price feed and the decimals recorded when the asset was added, and scales amount × price into USDC's minor units. With USDC's six decimals, that divides by 10^8 for a six-decimal asset on a −8 price feed, by 10^10 for an eight-decimal asset on the same price feed, and by 10^7 for an eight-decimal asset on a −5 price feed. A program that assumed one of those would value the others 10, 100 or 1,000 times too high or too low. `test_valuation_scales_by_decimals_and_exponent` runs this chapter's story with an eight-decimal TSLAx on a −5 price feed, `test_valuation_scales_by_nine_decimals_and_exponent` runs it again at nine decimals, and both get the same share counts. The price load rejects a price feed that is not the one recorded in the asset config (`InvalidPriceFeed`), a price that is not positive (`NegativePrice`), a price older than 60 seconds (`StalePriceFeed`), or a price posted at or before the cluster's last restart (`PricePredatesRestart`). It also rejects a price whose confidence interval is wider than 1% of the price (`OracleConfidenceTooWide`). The recorded USDC is added to the total. That total is the **net asset value**, the NAV. The program keeps these records itself and updates them with what each transfer actually moves; the failure-modes section says why it never values the fund from its vaults' token balances. On the fund's first deposit the NAV is zero and shares are minted one-to-one: Alice's 900 USDC becomes 900 shares. +**First it values the fund.** It walks every asset: reads the holding the program has recorded for that vault, loads the Pyth price, and accumulates value in u128, multiply before divide. A Pyth price is an integer and an exponent, so $250 arrives as 25,000,000,000 at exponent −8, or as 25,000,000 at exponent −5 on Pyth's US equity price feeds. A holding is in the asset's minor units, so 1.44 TSLAx is 144,000,000 at its eight decimals, and would be 1,440,000 at six. The handler reads the exponent from the price feed and the decimals recorded when the asset was added, and scales amount × price into USDC's minor units. With USDC's six decimals, that divides by 10^8 for a six-decimal asset on a −8 price feed, by 10^10 for an eight-decimal asset on the same price feed, and by 10^7 for an eight-decimal asset on a −5 price feed. A program that assumed one of those would value the others 10, 100 or 1,000 times too high or too low. `test_valuation_scales_by_decimals_and_exponent` runs this chapter's story with an eight-decimal TSLAx on a −5 price feed, `test_valuation_scales_by_nine_decimals_and_exponent` runs it again at nine decimals, and both get the same share counts. The price load rejects a price feed that is not the one recorded in the asset config (`InvalidPriceFeed`) and an update that is not fully verified, signed by fewer than a quorum of Pyth's signers (`PriceNotFullyVerified`, checked by `test_partially_verified_price_rejected`). It also rejects a price that is not positive (`NegativePrice`), a price older than 60 seconds (`StalePriceFeed`), a price posted at or before the cluster's last restart (`PricePredatesRestart`), and a price whose confidence interval is wider than 1% of the price (`OracleConfidenceTooWide`). The recorded USDC is added to the total. That total is the **net asset value**, the NAV. The program keeps these records itself and updates them with what each transfer actually moves, so a token transfer straight into a vault (the donation attack in the failure-modes section) cannot move the share price. On the fund's first deposit the NAV is zero and shares are minted one-to-one: Alice's 900 USDC becomes 900 shares. **Then it deploys the USDC at the weights.** 40% of 900, $360, buys TSLAx at $250: 1.44 TSLAx into the TSLAx vault. 60%, $540, buys NVDAx at $180: exactly 3 NVDAx. The swaps run through the router fixed at fund creation (the example ships a mock router standing in for Jupiter; a router that is not the configured one is rejected, checked by `test_deposit_rejects_unregistered_router`), and the fund PDA signs. The handler measures what each swap moved on the vaults and adds it to the recorded holdings. A swap that returns none of its asset refuses the whole deposit (`DepositTooSmall`). @@ -54,7 +54,7 @@ But the weights have drifted: NVDAx's gain pushed it to 62.5% of the fund agains Two rules decide whether there is a trade at all. The asset sold must sit above its target by at least the fund's **rebalance threshold**, which Maria set at 200 basis points, two percentage points of the fund's value. NVDAx's 2.5 points clears it; at 60.5% the call would fail with `DriftBelowThreshold`. The asset bought must sit below its own target, or the call fails with `NotUnderweight`. Because the trade closes the smaller gap exactly, a second call straight after finds nothing to sell. -Every rebalance pays slippage, so a threshold near zero would let anyone trade the fund on every small price move, and a very large one would let the fund drift far from the weights depositors chose. `initialize_fund()` accepts a threshold between one and twenty percentage points (`RebalanceThresholdOutOfRange`), and nothing can change it afterwards. One asset is exempt from it: an asset Maria has retired by setting its weight to zero may be sold down whatever the threshold, since selling it to zero can happen only once. Maria steers the fund through `set_weight()`, and the next rebalance carries out the new weights. +Every rebalance pays slippage, so a threshold near zero would let anyone trade the fund on every small price move, and a very large one would let the fund drift far from the weights depositors chose. `initialize_fund()` accepts a threshold between one and twenty percentage points (`RebalanceThresholdOutOfRange`), and nothing can change it afterwards. A retired asset is exempt from it: once Maria sets an asset's weight to zero, it may be sold down whatever the threshold, since selling it to zero can happen only once. Maria steers the fund through `set_weight()`, and the next rebalance carries out the new weights. ![](figures/fund-rebalance.svg) @@ -72,27 +72,29 @@ Traditional funds sell assets to pay the management fee. This program **mints ne ## Withdrawing In Kind -Alice takes profit with `withdraw()`, burning her 900 shares. The program does not sell anything to pay her: it transfers to her **her proportional slice of every vault, in kind**. That is her share of the recorded holdings in the USDC vault, the TSLAx vault (about 1.52), and the NVDAx vault (about 2.85), each amount floored, each destination account verified to be hers and of the right mint (`InvalidRecipient`). The share count drops by her 900 shares, and her `min_usdc_out` floor protects the USDC leg (`test_withdraw_rejects_slippage`). +Alice takes profit with `withdraw()`, burning her 900 shares. The program does not sell anything to pay her: it transfers to her **her proportional slice of every vault, in kind**. That is her share of the recorded holdings in the USDC vault, the TSLAx vault (about 1.52), and the NVDAx vault (about 2.85), each amount floored, each asset destination verified to be hers and of the right mint (`InvalidRecipient`). The share count drops by her 900 shares, and her `min_usdc_out` floor protects the USDC leg (`test_withdraw_rejects_slippage`). She entered with $900, held through NVIDIA's rise, paid Maria 1% by dilution, and left with about $950 of assets. Bob still holds 450 shares, unrealized. Maria holds 13.5 shares, about $14, for managing the fund. Victor took no fee on any of it. ![No price account appears here: every leg is a proportion of a recorded holding, not a quote.](figures/fund-withdraw-in-kind.svg) -## The Math: One Valuation Loop, Four Floors Toward the Fund, One Ceiling Toward the Manager +## The Math: Six Roundings Toward the Fund, One Toward the Manager -Every formula in the program is a proportion. Four round toward the fund and one, the fee, rounds toward the manager: +In the seven formulas below, the fee rounds toward the manager and the other six toward the fund: +- **A deposit's valuation:** each asset's value is rounded up before the shares are priced, so a deposit is minted no more shares than its USDC buys (`test_deposit_values_assets_rounding_up`). - **Shares minted:** deposit × supply ÷ NAV, floored. - **Withdrawal legs:** recorded holding × shares ÷ supply, floored per asset, with each payout subtracted from the record. - **Fee shares:** supply × rate × time over a year, rounded up. - **Deployment per leg:** deposit × weight ÷ 10,000, floored. - **Rebalance trade:** the smaller of the sell asset's excess over its target and the buy asset's shortfall below its own, converted to the sell asset at the oracle price, floored. +- **Rebalance sell floor:** the oracle value of what is sold × (10,000 − slippage tolerance) ÷ 10,000, rounded up, so the floor never sits below the exact figure (`test_rebalance_sell_floor_rounds_up`). -All of them use u128 intermediates and multiply before dividing. The four floored formulas leave their remainders with the fund rather than the transactor; the fee's remainder is minted to the manager. The one multiplication that is not a proportion is the valuation itself: amount × price, scaled by the asset's decimals and the price feed's exponent. +All of them use u128 intermediates and multiply before dividing. The floored formulas leave their remainders with the fund rather than the transactor; the fee's remainder is minted to the manager. The one multiplication that is not a proportion is the valuation itself: amount × price, scaled by the asset's decimals and the price feed's exponent. A deposit rounds it up and a rebalance floors it. ## The Invariants: Assets Stay in the Fund's Vaults -- From first deposit to last withdrawal, assets live only in fund-owned vaults. No instruction handler transfers vault tokens to the manager; the fee path mints shares and touches no vault. +- From first deposit to last withdrawal, assets live only in fund-owned vaults. No instruction handler transfers vault tokens to the manager, except what their fee shares redeem through `withdraw()`, as for any holder; the fee path mints shares and touches no vault. - Deposits are accepted only at 100% allocation, and every deposit is fully deployed in the same transaction. - NAV computations include every asset index or refuse to run. - Share supply changes only through `deposit()`, `withdraw()`, and `collect_fees()`, and `total_shares` moves before the corresponding CPIs. @@ -104,14 +106,14 @@ All of them use u128 intermediates and multiply before dividing. The four floore - **The manager is included in the threat model**, and the program treats them as such: assets only from Victor's list, swaps only through the fixed router, minimums only from the oracle, fee capped and immutable, and no handler lets them choose a trade. - **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. +- **Every deposit and rebalance values the fund from the price feeds.** `load_price()` refuses the six 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 1%, a typical slippage tolerance, would let a trade fill outside it. 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 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. +- **A deposit leg that spends USDC and buys nothing 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 -The suite (with hand-built Pyth price-update accounts injected so prices are deterministic) covers the full lifecycle end to end (`test_full_lifecycle`). It then checks each rule separately. Under-allocated funds refuse deposits, unapproved assets refuse listing, weight overflows refuse, the 17th asset refuses, and non-managers cannot set weights. Unregistered routers refuse, slippage floors reject on deposit and withdrawal, incomplete account lists refuse, and later depositors pay fair NAV. `test_full_lifecycle` checks after every step that moves tokens that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the *Adversary* chapter's attack and checks that the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` test the other two checks. Every rebalance test signs as a stranger, neither the manager nor a depositor. `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold`, and `test_rebalance_cannot_churn` check that a fund at its targets, within its threshold, or just rebalanced cannot be traded. `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset`, and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_deposit_rejects_price_from_before_restart` simulates a halt, checks that a deposit is refused while the prices predate the restart, and checks that it goes through once Pyth posts again. `test_wide_confidence_price_rejected` moves a price feed's confidence interval instead of its price. Copy the price injection: valuation code cannot be tested against prices you do not control, so make the price feed a fixture and move it deliberately. +The suite (with hand-built Pyth price-update accounts injected so prices are deterministic) covers the full lifecycle end to end (`test_full_lifecycle`). It then checks each rule separately. The program refuses an unapproved asset, a weight overflow, a 17th asset, a weight change by a non-manager, and a deposit into an under-allocated fund. It refuses unregistered routers and incomplete account lists, slippage floors reject on deposit and withdrawal, and later depositors pay fair NAV. `test_full_lifecycle` checks after every step that moves tokens that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the *Adversary* chapter's attack and checks that the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` checks the `DepositTooSmall` refusal, and `test_rebalance_ignores_donations` checks that donated tokens neither force nor fund a rebalance. Every rebalance test signs as a stranger, neither the manager nor a depositor. `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold`, and `test_rebalance_cannot_churn` check that a fund at its targets, within its threshold, or just rebalanced cannot be traded. `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset`, and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_deposit_rejects_price_from_before_restart` simulates a halt, checks that a deposit is refused while the prices predate the restart, and checks that it goes through once Pyth posts again. `test_wide_confidence_price_rejected` moves a price feed's confidence interval instead of its price. Copy the price injection: valuation code cannot be tested against prices you do not control, so make the price feed a fixture and move it deliberately. ## What You Learnt from Building the Managed Fund @@ -119,12 +121,12 @@ A mutual fund needs a custodian, a compliance officer, a fee schedule in a prosp The managed fund added five concepts: -- **The manager included in the threat model.** Every input the manager could abuse comes from somewhere else: the curator, the router fixed at creation, the oracle. Rebalancing moves tokens between program-owned vaults, in trades the program sizes. +- **The manager included in the threat model.** Every input the manager could abuse after creation comes from somewhere else or is fixed: assets and price feeds from the curator, prices from the oracle, and the router fixed when the fund is created. Rebalancing moves tokens between program-owned vaults, in trades the program sizes. - **A curator separate from a manager.** The curator's approved-asset set binds each asset to its official price feed; the manager chooses only among them. - **Oracle-anchored minimums.** When an untrusted party routes the trade, the acceptable outcome must be computed from a price source they do not control. -- **NAV as the share price, from the program's own records.** Value every holding with a fresh price feed or refuse to run. +- **A share price of NAV over supply, from the program's own records.** Value every holding with a fresh price feed or refuse to run. - **Fees by dilution, at a rate fixed at creation.** The fee mints shares against every holder in proportion. In the *Options* chapter we will build a program that takes no credit risk at all. A writer posts the whole of what they might owe before the option is sold, so there is no margin, no liquidator, and no oracle, and you will see how much simpler the program is for it. The *Perpetual Futures* chapter, the last program chapter, then builds a program that needs all of it at once: vaults, shares, oracles, funding, margin, and liquidation. -[^separate-people]: The program sees two public keys, not two people, and nothing onchain stops one person holding both the curator's and the manager's private keys. Keeping Victor and Maria separate people needs proof from outside the program, from a proof-of-personhood service like those in the *Fundraiser* chapter's footnote. +[^separate-people]: The program sees two public keys, not two people, and nothing onchain stops one person holding both the curator's and the manager's private keys. Keeping Victor and Maria separate people needs proof from outside the program, from a proof-of-personhood service such as World ID or VeryAI (the *Fundraiser* chapter's Sybil boxout). diff --git a/building-financial-products-in-solana/options.md b/building-financial-products-in-solana/options.md index b64debc6..6eb12589 100644 --- a/building-financial-products-in-solana/options.md +++ b/building-financial-products-in-solana/options.md @@ -6,14 +6,14 @@ Bob thinks NVIDIA will pass $180 within the week, and he wants that upside for l An **option** serves them both: Alice sells Bob the right, but not the obligation, to buy her five NVDAx at $180 any time in the next week, and Bob pays her 25 USDC for it now. If NVIDIA rallies, Bob buys the shares below the market. If it does not, he loses the 25 USDC and Alice keeps it as income on shares she still owns. -Alice's offer to sell five NVDAx for 900 USDC, 180 USDC a share, is an ordinary offer with two additions: it expires, and the buyer pays to reserve it. The program lives at `finance/options` in the companion repository. It keeps one account per option, and the whole lifecycle is three states: `Listed`, `Held`, and `Exercised`. +Alice's offer to sell five NVDAx for 900 USDC, 180 USDC a share, is an ordinary offer with two additions: it expires, and the buyer pays to reserve it. The program lives at `finance/options` in the companion repository. It keeps one account per option, and the lifecycle has three states: `Listed`, `Held`, and `Exercised`. ## An Option Is a Right on One Side and an Obligation on the Other Options have their own terms: - An option is a **contract**: an agreement between the two parties to it. -- Its **underlying**, short for "underlying asset", is the asset the option is about: NVDAx in this chapter. Options traders drop the word "asset" and just say "the underlying". Markets write a pair as base/quote, NVDAx/USDC, and the underlying is the base token, the first of the two. USDC, which the strike and premium are paid in, is the quote token. +- Its **underlying**, short for "underlying asset", is the asset the option is about: NVDAx in this chapter. Options traders drop the word "asset" and just say "the underlying". On the NVDAx/USDC pair the underlying is the base token, and USDC, which the strike and premium are paid in, is the quote token (both defined in the *Order Book Exchange* chapter). - Its **kind** is either a **call**, the right to buy the underlying, or a **put**, the right to sell it. - Its **strike** is the price the trade happens at if the right is used. - Its **expiry** is the moment the right lapses: the holder may exercise before it, and not at or after it. @@ -52,9 +52,9 @@ A put is a call with the two tokens swapped: the writer posts the strike instead | The holder profits past | the strike plus the premium | the strike minus the premium | | In this chapter | Alice writes, Bob buys: strike $180, premium $5 a share, profit past $185 | Carol writes, Dave buys: strike $150, premium $4 a share, profit past $146 | -Traditional options venues let a writer take on the obligation with only a fraction of it posted as **margin**, and then watch the position every day to make sure the fraction still covers it. **A writer posts the whole obligation, up front, into the vault**, which is the table's second row. That makes a call **covered** and a put **cash-secured**: what the holder might claim is already in custody, so the writer cannot fail to deliver or to pay. +Traditional options venues let a writer take on the obligation with only a fraction of it posted as **margin**, and then watch the position every day to make sure the fraction still covers it. **In this program, a writer posts the whole obligation up front, into the vault**, which is the table's second row. That makes a call **covered** and a put **cash-secured**: what the holder might claim is already in custody, so the writer cannot fail to deliver or to pay. -Nothing in this program is ever undercollateralized, so it has no margin, no health factor, no liquidator, and no oracle. +Nothing in this program is ever undercollateralized, so it has no margin, no health to measure, no liquidator, and no oracle. ## Settlement Is Physical, So No Price Is Read @@ -69,7 +69,7 @@ Maria runs the venue. Her incentive is a fee of 1% on every premium paid, and he - A venue whose underlying and quote are the same token. - A fee of 100% or more, which would leave a writer nothing from a sale. -A fee of zero is allowed: a venue run at cost is a valid choice. `test_zero_fee_venue_pays_the_writer_the_whole_premium` checks that the writer receives all 25 USDC and, counting the transaction's inner instructions, that the purchase made one token transfer: `buy_option()` skips the fee transfer rather than sending one for zero. Her public key is stored as the market's `admin`, and the only handler that reads it is `collect_fees()`. +A fee of zero is allowed: a venue run at cost is a valid choice. `test_zero_fee_venue_pays_the_writer_the_whole_premium` checks that the writer receives all 25 USDC and, counting the transaction's inner instructions, that the purchase made one token transfer: `buy_option()` skips the fee transfer rather than sending one for zero. Maria's public key is stored as the market's `admin`, and the only handler that reads it is `collect_fees()`. ![](figures/options-open-venue.svg) @@ -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 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. +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. Every handler that moves tokens updates the three counters 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 @@ -100,8 +100,6 @@ Then the handler does four things in order: 3. `check_custody()` runs. 4. Alice's 5 NVDAx move into the underlying vault. -From this moment the vault holds everything a future holder could ever claim from her. - ![](figures/options-write-call.svg) The transaction also created the option itself. Each option is an `OptionContract` account at a PDA with seeds `"option"`, the market, the writer, and an `id` the writer chooses, so one writer can have many options open. Its fields are the five terms plus the bookkeeping around them: @@ -114,29 +112,32 @@ The transaction also created the option itself. Each option is an `OptionContrac ![](figures/options-state-machine.svg) -**Every amount the option ever moves is one the account stores.** A call writer posts `underlying_amount` and a call holder pays `strike_amount`; a put swaps the roles, so the writer posts the strike amount and the holder delivers the underlying amount. +**Every amount settlement moves is one the account stores.** A call writer posts `underlying_amount` and a call holder pays `strike_amount`; a put swaps the roles, so the writer posts the strike amount and the holder delivers the underlying amount. -There is no strike *price* to multiply by a quantity and divide by a decimals factor, so settlement does no arithmetic at all, and has no rounding direction to get wrong. The chapter's "180 USDC strike" is the 900 USDC strike amount spread over the five shares it buys. +There is no strike *price* to multiply by a quantity and divide by a decimals factor, so settlement does no arithmetic at all, and has no rounding direction to get wrong. A "180 USDC strike" is the 900 USDC strike amount spread over the five shares it buys. The handler would also create Alice's USDC account, where her premium will arrive, at her expense if she did not already have one, so that `buy_option()` can require it to exist rather than make the buyer pay rent on the writer's account. -Nobody has paid anything yet. Until someone does, Alice can call `cancel_option()` at any time, which returns her collateral and closes the account, rent back to her. That includes after the expiry: an unsold option has no holder whose rights the cancel would cut short, and `test_cancel_unsold_option_works_after_expiry` checks it. Without `cancel_option()`, the shares a writer posted for an option nobody buys would stay in the market's vault forever. +Nobody has paid anything yet. Until someone does, Alice can call `cancel_option()` at any time, which returns her collateral and closes the account, rent back to her. That includes after the expiry: an unsold option has no holder whose rights the cancel would cut short, and `test_cancel_unsold_option_works_after_expiry` checks it. Without `cancel_option()`, the collateral a writer posted for an option nobody buys would stay in the market's vault forever. ## A Buyer Pays the Premium, and the Collateral Does Not Move -Bob calls `buy_option()`. The handler does four things: +Bob calls `buy_option()`, passing an `OptionTerms` struct with the terms he read from the option before signing. The handler does five things: - It requires the option to be `Listed` and unexpired (`OptionNotListed`, `OptionExpired`), because a right nobody can exercise any more is not for sale. +- It requires the option's kind, underlying amount, strike amount, premium, and expiry to equal the ones Bob passed (`OptionTermsChanged`). - It splits the premium: `fee_bps` of it, 0.25 USDC, is the fee, and the remaining 24.75 USDC goes straight from Bob to Alice. - It sends the fee into the quote vault and adds it to `fees_owed`. - It sets the option's `holder` to Bob and its `status` to `Held`. ![](figures/options-buy-call.svg) -The premium is the only payment in the purchase, and it is Alice's from this moment whatever Bob does later. The 5 NVDAx do not move. Bob's downside is fixed at the 25 USDC he just paid, and Alice's upside on the shares is capped at $180 until the week is out. +The premium is the only payment in the purchase, and the 24.75 USDC of it that reaches Alice is hers from this moment whatever Bob does later. The 5 NVDAx do not move. Bob's downside is fixed at the 25 USDC he just paid, and Alice's upside on the shares is capped at $180 until the week is out. A writer cannot buy their own option. In the Anchor build, the premium's source and destination are each derived as their party's associated token account, so with the writer on both sides the same account would fill two mutable slots. Anchor refuses the instruction with `ConstraintDuplicateMutableAccount` before the handler runs. `test_writer_cannot_buy_their_own_option` asserts that error and that the option is still `Listed`. A second buyer is refused because the option is no longer `Listed` (`test_buy_is_refused_once_sold`). +The terms check stops the switched offer of the *Escrow* chapter. While Bob's purchase is on its way, Alice could cancel and write a new option at the same address, with the same `id`, on worse terms. `test_buy_option_refuses_a_switched_option` does that four times, with a higher premium, fewer shares, a higher strike, and a sooner expiry. Each purchase at the terms Bob saw fails with `OptionTermsChanged`, and no USDC moves. + ## The Holder Exercises Before Expiry, and the Tokens Change Hands NVIDIA reports earnings and NVDAx moves to $200. Bob calls `exercise_option()` before the week is out. The handler requires the option to be `Held`, the clock to be before `expiry` (`OptionNotHeld`, `OptionExpired`), and the signer to be the holder. Then it updates the ledger and moves both legs: @@ -148,7 +149,7 @@ NVIDIA reports earnings and NVDAx moves to $200. Bob calls `exercise_option()` b ![](figures/options-exercise.svg) -Bob now holds 5 NVDAx worth $1,000 and has spent 925 USDC to get them, 25 of premium and 900 of strike. His 1,000 USDC is 75 USDC. Had NVDAx stayed at $165, he would not have exercised (paying 900 for shares the market sells for 825), and the 25 USDC would have been his whole loss. The call would have expired, and once the week was out Alice would have called `reclaim_collateral()` for her 5 NVDAx, keeping the premium and the shares. +Bob now holds 5 NVDAx worth $1,000 and has spent 925 USDC to get them, 25 of premium and 900 of strike. Of his 1,000 USDC, 75 is left. Had NVDAx stayed at $165, he would not have exercised (paying 900 for shares the market sells for 825), and the 25 USDC would have been his whole loss. The call would have expired, and once the week was out Alice would have called `reclaim_collateral()` for her 5 NVDAx, keeping the premium and the shares. ![](figures/options-call-expires.svg) @@ -166,7 +167,7 @@ Alice sold her five NVDAx for 900 USDC and had already been paid 24.75 USDC for ## A Put Writer Posts Cash, and the Option Expires Unexercised -Carol would like to own NVDAx at $150 and would like to be paid for standing ready to buy. She calls `write_option()` with kind `Put`, an underlying amount of 5 NVDAx, a strike amount of 750 USDC (150 USDC a share), a premium of 20 USDC, and an expiry a week out. A put writer's collateral is the strike amount, so 750 USDC leaves Carol's account for the quote vault, and `quote_owed` rises to match. Her 1,000 USDC is 250. +Carol would like to own NVDAx at $150 and would like to be paid for standing ready to buy. She calls `write_option()` with kind `Put`, an underlying amount of 5 NVDAx, a strike amount of 750 USDC (150 USDC a share), a premium of 20 USDC, and an expiry a week out. A put writer's collateral is the strike amount, so 750 USDC leaves Carol's account for the quote vault, and `quote_owed` rises to match. Of her 1,000 USDC, 250 is left. Carol has no NVDAx account, so the handler also creates one, at her expense; `cancel_option()` and `reclaim_collateral()` would create it the same way. ![](figures/options-write-put.svg) @@ -208,7 +209,7 @@ Here is where everyone ends up: The arithmetic lives in `contract_math.rs`: - **Settlement.** `collateral_amount()` returns the underlying amount for a call and the strike amount for a put, and `exercise_payment()` returns the other. -- **The premium split.** `split_premium()` rounds the fee up, `premium` times `fee_bps` over 10,000, and gives the writer the rest. The venue keeps the rounding minor unit, so a writer gives up at most one minor unit per sale and a premium of one minor unit goes entirely to the venue. +- **The premium split.** `split_premium()` rounds the fee up, `premium` times `fee_bps` over 10,000, and gives the writer the rest. The venue keeps the rounding minor unit, so a writer gives up at most one minor unit per sale and, at any nonzero fee, a premium of one minor unit goes entirely to the venue. - **The expiry.** `may_exercise()` is `now < expiry` and `may_reclaim()` is `now >= expiry`. Every checked operation on a counter maps to `MathOverflow`; a vault balance that would go below zero maps to `CustodyInvariantViolated`. @@ -229,24 +230,24 @@ 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 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. +- **What is absent.** There are no shares to inflate, and the program has no margin, oracle, or liquidator to attack. 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 The suite (`tests/test_options.rs`) follows the call from write to collected strike and from purchase to expiry and reclaim. It follows the put from write to exercise (`test_put_lifecycle_delivers_the_underlying_for_the_strike`) and from purchase to expiry and reclaim (`test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer`). Every balance is asserted to the minor unit, and the lifecycle tests call the custody check's equality form after every step. Around the happy paths there is one test per refusal, and each asserts the error code the refusal fails with, the program's own or Anchor's, rather than only that the transaction failed: - The expiry boundary, from both sides. -- Cancel after a sale, and buy after a sale or after expiry. +- Cancel after a sale, and buy after a sale, after expiry, or at terms the writer has switched. - Exercise by anyone but the holder, and collection by anyone but the writer or before an exercise. - Reclaim after an exercise, fee collection by anyone but the admin, and a second collection with nothing owed. - The three zero terms, and a writer buying their own option. - An expiry already past, and a venue opened with the same token on both sides or a fee of 100%. -When you build your own, copy the shape of the two boundary tests: a deadline needs one test one second inside it and one at it. A test that set the deadline to zero would have no second inside it to test from, so the suite could pass without ever showing that exercise works before expiry. +When you build your own, copy the shape of the two boundary tests: a deadline needs one test one second inside it and one at it. A suite that tested only at the expiry could pass without ever showing that exercise works one second before it. ## What You Learnt from Building the Options Venue -An options exchange needs a clearing house standing behind every writer, a margin department watching every account, and a settlement desk on expiry day. In this program, making the writer post the whole obligation before they sell the right does all three jobs. The clearing house is the vault, the margin department has nothing to watch, and settlement is two token transfers the holder triggers. +An options exchange needs a clearing house standing behind every writer, a margin department watching every account, and a settlement desk on expiry day. In this program the writer posts the whole obligation before selling the right. The vault holds what a clearing house would guarantee, there is no margin to watch, and settlement is two token transfers the holder triggers. The options venue added six concepts: @@ -254,10 +255,10 @@ The options venue added six concepts: - **Full collateralization removes margin, liquidation and the oracle.** A covered call and a cash-secured put cannot default. - **Physical settlement needs no price.** The holder's decision replaces the price feed, and the program's job is to enforce the terms, not to value them. - **Store the amounts that change hands, not a price.** An option that records its underlying and strike amounts settles by moving them. -- **A ledger for mixed custody.** When one vault holds several parties' tokens, the state account counts what each is owed and every handler asserts the vault still covers it, after the math. +- **A ledger for mixed custody.** When one vault holds several parties' tokens, the state account counts what each is owed and every handler that moves tokens asserts the vault still covers it, after the math. - **One deadline, compared both ways.** At every moment exactly one party can claim the collateral. -In the *Perpetual Futures* chapter we will relax the collateral rule. Traders post a fraction of their exposure, an oracle values the rest continuously, a haircut pays each winner only as far as the pool can back it, and a liquidator is paid to close what the fraction no longer covers. +In the *Perpetual Futures* chapter we will relax the collateral rule. Traders post a fraction of their exposure, a price feed values each position continuously, a haircut pays each winner only as far as the pool can back it, and a liquidator is paid to close what the fraction no longer covers. [^psyoptions]: PsyOptions ran physically settled, fully collateralized options on Solana, with no oracle, and this chapter's venue follows its settlement design. It launched on mainnet on 22 August 2021 and was, by its own account, the first options program live there; see [x.com/PsyOptions/status/1436041089518030849](https://x.com/PsyOptions/status/1436041089518030849). PsyOptions represented each option series as a token, one mint per series; this program keeps one account per option instead, for a simpler custody ledger. diff --git a/building-financial-products-in-solana/order-book.md b/building-financial-products-in-solana/order-book.md index 49086f88..c25a9bf5 100644 --- a/building-financial-products-in-solana/order-book.md +++ b/building-financial-products-in-solana/order-book.md @@ -2,7 +2,7 @@ NYSE, NASDAQ, and the CME are order book exchanges. On Solana, Manifest, OpenBook, and Phoenix are order book exchanges, and by mid-2026 Manifest was carrying more spot volume than any other order book on the network.[^order-book-venues] All of them work the same way: orders arrive carrying a price their owner refuses to cross, wait in two sorted lists, and trade when one order reaches across the gap. The three on Solana settle out of vaults the venue's operator cannot withdraw from. -The program lives at `finance/order-book` in the companion repository. It assumes you know the escrow pattern from the *Escrow* chapter: tokens held in program-owned vaults, terms recorded in data accounts, and every movement allowed only when the right account signs. An exchange is that pattern scaled up from one deal to thousands of simultaneous ones. +The program lives at `finance/order-book` in the companion repository. It assumes you know the escrow pattern from the *Escrow* chapter: tokens held in program-owned vaults, terms recorded in data accounts, and every movement allowed only with the right signature. An exchange is that pattern scaled up from one deal to thousands of simultaneous ones. ## A Limit Order Is a Price You Refuse to Cross @@ -10,7 +10,7 @@ A **limit order** is an order with a price limit attached. A **bid** says "I wil ![](figures/order-book-bids-asks-spread.svg) -The other model for an exchange is the automated market maker, where there are no limit orders and every trade fills immediately against a pooled formula; that is the next chapter. An order book is better at price discovery, because every price on it is a commitment someone funded. An AMM is better for new assets without much liquidity around them. +The other model for an exchange is the automated market maker, where there are no limit orders and every trade fills immediately against a pooled formula, the design the *Constant-Product AMM* chapter builds. An order book is better at price discovery, because every price on it is a commitment someone funded. An AMM is better for new assets without much liquidity around them. One naming note: this design is traditionally called a "central limit" order book, because the book lives in one central place. Here that place is a Solana account, so the "central" book is stored on a decentralized network. @@ -18,7 +18,7 @@ One naming note: this design is traditionally called a "central limit" order boo The first instinct is to store the bids and asks as sorted arrays. Arrays get slow when an exchange gets busy: inserting into the middle of a sorted list means shifting everything after it, and on a chain where compute is metered, your users pay that cost. -So the book's two sides are **critbit trees**. Each resting order gets a fixed-size binary key, and the tree branches at the first bit where keys differ (the "critical bit"). Inserting an order means walking to where its key diverges and adding one node there; nothing shifts. The key is built from the price in the high bits and a sequence number in the low bits, which puts the tree in exchange order: walking it visits orders best price first, and within a single price, earliest order first. That ordering has a name, **price-time priority**, and it is the fairness rule the major exchanges use. +So the book's two sides are **critbit trees**. Each resting order gets a fixed-size binary key, and the tree branches at the first bit where keys differ (the "critical bit"). Inserting an order means walking to where its key diverges and adding a branch there with the new leaf beneath it; nothing shifts. The key is built from the price in the high bits and a sequence number in the low bits, which puts the tree in exchange order: walking it visits orders best price first, and within a single price, earliest order first. That ordering has a name, **price-time priority**, and it is the fairness rule the major exchanges use. A critbit tree does not balance itself, so an attacker can place asks at 2, 4, 8, and so on to deepen the tree. That only adds a little compute. The deepest path doubling prices can build is 64 levels: 63 from the prices and one more from the sequence number that separates two orders at the same price. That path adds under 15,000 extra compute units per instruction, out of the 200,000 an instruction is granted by default at the time of writing (the suite's `DEFAULT_INSTRUCTION_COMPUTE_UNITS`). @@ -30,11 +30,11 @@ The market this chapter builds trades NVDAx over USDC: NVDAx, a stock, is the ** 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. +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 writes the order book's header (its market, and the first order ID) and a side tag on each tree. 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] +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. 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] ![](figures/order-book-initialize-market.svg) @@ -46,11 +46,11 @@ The **order book** is the largest account: about 180 KB holding two trees of res > **Why the order book is not at a PDA.** Solana caps account allocations made from inside a program at 10 KB, so `initialize_market()` cannot create a 180 KB account. Instead, the client creates the account directly with the System Program before calling `initialize_market()`, sized to `ORDER_BOOK_ACCOUNT_SIZE` and owned by the program. And its address cannot be a PDA at all, because creating an account that way requires the new account's own signature, which a PDA cannot give. The program instead ties the book to its market with a stored-address check: the market records the book's address, and every instruction that touches the book checks it against that record. So the book's address is a public key, and the figures draw its dot on the curve, with the book's box above it at the top right. Its private key signs once, at creation; after that the program owns the account and the private key can do nothing to it. -Three **vaults** hold the tokens: a base vault for NVDAx, a quote vault for USDC, and a separate **fee vault** for Maria's earnings. Unlike the book, all three live at PDAs derived from the market (seeds: `"base_vault"`, `"quote_vault"` or `"fee_vault"`, then the market), and `initialize_market()` creates them with the market PDA as their authority. The fee vault holds the same token as the quote vault. Keeping them separate means user-owed balances and the market's fees never share one balance, so `withdraw_fees()` can pay out the whole fee vault. The failure modes section shows the attack two same-token vaults invite, and the check that blocks it. +Three **vaults** hold the tokens: a base vault for NVDAx, a quote vault for USDC, and a separate **fee vault** for Maria's earnings. Unlike the book, all three live at PDAs derived from the market (seeds: `"base_vault"`, `"quote_vault"` or `"fee_vault"`, then the market), and `initialize_market()` creates them with the market PDA as their authority. The fee vault holds the same token as the quote vault. Keeping them separate means user-owed balances and the market's fees never share one balance, so `withdraw_fees()` can pay out the whole fee vault. Because the two hold the same token under the same authority, every handler that moves tokens through a vault checks its address against the one the market recorded, so the fee vault cannot be passed where the quote vault belongs. ## The Exchange Records What It Owes Each Trader -Three traders use the market in this chapter: Alice, Bob, and Carol, each starting with the standard wallet of one SOL and 1,000 USDC, plus the NVDAx the two sellers hold: ten shares for Alice, three for Carol. Before trading, each of them calls `initialize_market_user()` once. It creates their market user account with zeroed unsettled balances and an empty open-order list. It is where the program records what it owes them: the NVDAx and USDC the exchange will pay them on request. +Three traders use the market in this chapter: Alice, Bob, and Carol, each starting with the standard wallet of one SOL and 1,000 USDC, plus the NVDAx the two sellers hold: ten shares for Alice, three for Carol. Before trading, each of them calls `initialize_market_user()` once. It creates their market user account with zeroed unsettled balances and an empty open-order list. That **market user** account (seeds: `"market_user"`, the market, the owner) tracks their `open_orders` (capped at 20) and two numbers that keep each fill's compute low: `unsettled_base` and `unsettled_quote`, the amounts the program owes them. When the program takes tokens from a user it transfers them immediately into a vault; when it owes tokens to a user it just increments these counters. Users claim what they are owed whenever they like, and many small payouts consolidate into one transfer, saving compute units on the path every fill takes. @@ -90,7 +90,7 @@ Now the program credits the unsettled balances. Gross proceeds are 3 × 250 = 75 ![](figures/order-book-match.svg) -Bob did nothing at all. Like Alice in the *Escrow* chapter, a maker signs once when placing and the program handles their side of every subsequent fill. +Bob signed nothing for this fill: like Alice in the *Escrow* chapter, a maker signs once when placing and the program handles their side of every subsequent fill. ## Canceling Returns the Unfilled Order's Tokens @@ -98,7 +98,7 @@ Bob decides three shares is enough. He calls `cancel_order()` on his order accou ![](figures/order-book-cancel.svg) -To change a resting order's size or price, Bob cancels it and places a new one, in one transaction so either both happen or neither does; the new order goes to the back of the queue at its price. +To change a resting order's size or price, a trader cancels it and places a new one, in one transaction so either both happen or neither does; the new order goes to the back of the queue at its price. ![](figures/order-book-state-machine.svg) @@ -137,13 +137,13 @@ With nothing open or owed, each of them can also call `close_market_user()`, whi - For every order still on the book, the vaults hold everything that order would pay if the rest of it filled: the USDC for a bid (price × remaining quantity × quote lot size) and the NVDAx for an ask. The vaults always hold at least those amounts for every resting order, plus all unsettled balances. - Every minor unit an order paid into a vault is accounted for when it leaves: a fill splits what was held for it into maker proceeds, taker rebate, and fee, and those three always sum exactly to what was held for that fill. Cancellation and eviction each return exactly what was held for the unfilled remainder. - `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. +- The program moves nothing into the fee vault except 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 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 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 handler that moves tokens through a vault, binding each vault it takes to the address recorded on the market at creation, and `settle_funds_rejects_fee_vault_substituted_for_quote_vault` checks that the attack fails. This is the account substitution the *Adversary* chapter covers, 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.** 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. @@ -164,7 +164,7 @@ The suite in `tests/test_order_book.rs` builds all state through the program's o - Closing accounts: `close_order_returns_a_cancelled_orders_rent`, `close_order_returns_a_filled_orders_rent`, `close_order_refuses_a_resting_order`, `close_order_refuses_a_partially_filled_order`, `close_order_refuses_a_non_owner`, `close_market_user_returns_rent_when_nothing_is_open_or_owed`, `close_market_user_refuses_an_open_order`, `close_market_user_refuses_an_unsettled_balance`, and `close_market_user_refuses_a_non_owner`. - Eviction, each test starting from a bid side filled with 512 orders: `full_side_refuses_an_order_no_better_than_its_worst`, `better_order_evicts_the_worst_and_rests`, `evicted_maker_settles_their_refund`, `eviction_rejects_missing_or_wrong_evicted_accounts`, and `trader_can_evict_their_own_worst_order`. -If you write one test after reading this chapter, write the vault-swap one for your own program. This bug appears wherever two accounts hold the same token under the same authority and your accounting has to keep them apart; test that your program does. +If you write one test after reading this chapter, write the vault-swap one for your own program. Any program with two accounts holding the same token under the same authority is open to it; write a test that passes one in place of the other and asserts the transaction fails. ## What You Learnt from Building the Order Book Exchange @@ -176,7 +176,7 @@ The order book added six concepts on top of the escrow's: - **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 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. +- **Vault separation plus stored-address checks.** The fee vault and the quote vault stay apart only because every handler that moves tokens through them 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. In the *Constant-Product AMM* chapter we will build a market with no order book. Two vaults hold the pair of tokens, and a formula sets the price from their balances, so anyone can trade at any time without waiting for a counterparty to post an order. The people who fill the vaults earn a fee on every trade, and that chapter calculates impermanent loss, the cost the fee compensates them for. diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index 47c107e6..2acdc8f9 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -8,7 +8,7 @@ The program lives at `finance/perpetual-futures` in the companion repository. It In the *Order Book Exchange* chapter, every buyer met a seller: Carol's ask crossed Bob's bid, and the program only recorded the result. A perp that trades against a pool has no orders to match. **Liquidity providers fund one pool, and the pool is the counterparty to every position.** When a trader wins, the pool pays; when a trader loses, the pool keeps the loss. So the LPs hold the opposite of the traders' net position: short NVDAx while more traders are long, long while more are short. An LP's return is the losing traders' losses minus the winning traders' profits, plus the net funding described below. The open and close fees do not go to the LPs: half of each goes to the pool's creator and half to an insurance fund. -That design decision creates the problem the program is organized around: the program must be able to check that the pool can pay every winner, at every moment, **without ever iterating over the open positions**. A program that must touch every position to answer "am I solvent?" stops fitting in a transaction once it has enough open positions. That requirement explains almost every field in the account model. +So the program has to check that the pool can pay every winner, at every moment, **without iterating over the open positions**: a program that must touch every position to answer "am I solvent?" stops fitting in a transaction once it has enough of them. ![](figures/perps-counterparty.svg) @@ -31,7 +31,7 @@ The two margins are the two collateral thresholds every leveraged venue has, mea The initial margin and the **maximum leverage** are the same rule stated two ways: maximum leverage is one divided by the initial margin, so a 10% initial margin is 10x and a 5% initial margin would be 20x. Traders usually quote the leverage; the program stores the margin, so both thresholds are in the same unit. The gap between the two is the room a new position has before it can be liquidated, so `initialize_pool()` refuses an initial margin at or below the maintenance margin with `InitialMarginNotAboveMaintenance` (checked by `test_initialize_pool_rejects_initial_margin_at_or_below_maintenance`). -The handler validates each parameter against `BASIS_POINTS_DENOMINATOR`, and a second cross-check prevents a dead state: `maintenance_margin_bps` must exceed `close_fee_bps`. Otherwise a position could exist that is too healthy to liquidate but too poor to pay its own close fee, and neither `close_position()` nor `liquidate_position()` would accept it, leaving its owner no way to get their collateral out. The test `test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin` checks it. So the requirement that every participant must be able to get their assets out of every state is enforced in the market's *configuration*, before any position exists. +The handler validates each parameter against `BASIS_POINTS_DENOMINATOR`, and a second cross-check prevents a dead state: `maintenance_margin_bps` must exceed `close_fee_bps`. Otherwise a position could exist that is too healthy to liquidate but too poor to pay its own close fee, and neither `close_position()` nor `liquidate_position()` would accept it, leaving its owner no way to get their collateral out. The test `test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin` checks it. ![](figures/perps-create-pool.svg) @@ -42,7 +42,7 @@ The **pool** account lives at a PDA with seeds `"pool"`, the collateral mint, an - `liquidity`: what the LPs own. It grows with deposits, trader losses, and funding the heavier side pays; it shrinks with withdrawals, trader profits, and funding paid to the lighter side. - `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`. +- `insurance_fund`: the share of every open and close fee set aside to pay losses larger than a position's collateral, and profits larger than `liquidity`. - `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. @@ -63,7 +63,7 @@ Eleven more providers follow Carol in over the morning, and the pool opens for t ## The Price Comes From Outside, So Every Handler Checks It Before Using It -Every trading and liquidity handler begins the same way: read the oracle, validate it, accrue funding, and update the pool's average of the price. The pool records two things at creation: one price feed account, the `oracle_feed`, and the program that owns it (`price_feed_program`, read from the account's owner at that moment). The price feed is the *Offchain Truth* chapter's signed external fact. In the companion repository the price feed is `mock-price-feed`, a program labeled not-for-production whose authority sets the price with `set_price()`, so the tests can move it; the production path is a real price feed network's account, decoded and verified with that network's own crate. Either way the program runs seven checks on what it reads, in order: +Every trading and liquidity handler begins the same way: read the oracle, validate it, accrue funding, and update the pool's average of the price. The pool records three things about its price feed at creation: the account, `oracle_feed`; the program that owns it, `price_feed_program`, read from the account's owner at that moment; and its decimal scale, `oracle_scale`. The price feed is an oracle's published price, as the *Offchain Truth* chapter describes. In the companion repository the price feed is `mock-price-feed`, a program labeled not-for-production whose authority sets the price with `set_price()`, so the tests can move it; the production path is a real price feed network's account, decoded and verified with that network's own crate. Either way the program runs seven checks on what it reads, in order: - the price feed account is owned by the program the pool recorded (`PriceFeedNotFromOracle`), checked before a byte of it is decoded, so an account laid out like a price feed but written by another program is never a price, - the account is long enough to contain a price (`OracleDataTooShort`), @@ -77,7 +77,7 @@ With leverage, a stale price lets anyone trade this market against a live one el ## The Pool Averages the Price Itself, and Trades Only Near the Average -The seven checks refuse a price from the wrong program, or one that is malformed, stale, or uncertain. A price feed can also publish a confident, fresh price that is wrong, because the markets it reads from were pushed for a few minutes: the *Adversary* chapter's Mango Markets story is that attack. So the pool keeps its own time-weighted average of the oracle's price, in `average_price`, and refuses to trade at a price too far from it. +The seven checks refuse a price from the wrong program, or one that is malformed, stale, or uncertain. A price feed can also publish a confident, fresh price that is wrong, because the markets it reads from were pushed for minutes on end (the Mango Markets push in the *Adversary* chapter took about half an hour). The pool cannot tell such a price from a real one, but it can refuse a sudden jump: it keeps its own time-weighted average of the oracle's price, in `average_price`, and refuses to trade at a price too far from it. The average updates every time a handler reads the oracle. `last_oracle_price` holds the price seen at the previous read, and the program assumes that price held until now: @@ -89,13 +89,13 @@ A price only starts counting from the moment it is read. A manipulated price mov `open_position()`, `close_position()`, `add_liquidity()` and `remove_liquidity()` compare the oracle's price with the stored average before folding it in. A price more than `max_price_deviation_bps` from the average, 20% in this pool, fails with `PriceOutsideBand` (checked by `test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band` and `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`). -`liquidate_position()` folds the price in without the band check. A genuine crash is when positions go underwater, and if liquidations waited for the average to catch up, the losses would grow in the LPs' pool while they waited. `test_liquidation_runs_outside_band` checks that a liquidation succeeds where a close at the same price is refused. +`liquidate_position()` folds the price in without the band check. Positions go underwater in a genuine crash, and if liquidations waited for the average to catch up, the losses would grow in the LPs' pool while they waited. `test_liquidation_runs_outside_band` checks that a liquidation succeeds where a close at the same price is refused. After a genuine move, the average has to catch up before anyone can trade again. Anyone may call `update_price_average()`, which reads and checks the oracle, accrues funding, and folds the price in, with no band check. Calling it as time passes pulls the average toward the new price: `test_price_average_catches_up_after_genuine_move` moves the price to $130 and calls it every two minutes, and trading reopens on the third call, with the average at $110.80. Traders who want to trade and LPs who want to withdraw both have a reason to send it. ## Opening a Long Sets Nothing Aside -Alice goes long first. She calls `open_position()`: side Long, 1,000 USDC collateral, 5,000 USDC size, and an acceptable-price bound (her slippage check; a long opening above it fails with `SlippageExceeded`). Size five times her collateral means **5x leverage**: every 1% move in NVDAx moves her equity by about 5%. The handler charges the open fee on **notional size, not collateral**, the full exposure the position controls: 10 basis points of 5,000 is 5 USDC, so her working collateral is 995. Fees scale with exposure because exposure, not deposit, is what the pool underwrites. Its last check is the **initial margin**: net collateral must be at least `initial_margin_bps` of the size. 10% of 5,000 is 500, and her 995 passes. With her 1,000 USDC the largest position she can open is about 9,900 USDC, since the fee rises with the size; she is opening about 5x. Anything larger fails with `InitialMarginNotMet` (checked by `test_open_rejects_position_below_initial_margin`). +Alice goes long first. She calls `open_position()`: side Long, 1,000 USDC collateral, 5,000 USDC size, and an acceptable-price bound (her slippage check; a long opening above it fails with `SlippageExceeded`). Size five times her collateral means **5x leverage**: every 1% move in NVDAx moves her equity by about 5%. The handler charges the open fee on **notional size, not collateral**, the full exposure the position controls: 10 basis points of 5,000 is 5 USDC, so her working collateral is 995. Fees scale with exposure because exposure, not deposit, is what the pool underwrites. Its last check is the **initial margin**: net collateral must be at least `initial_margin_bps` of the size. 10% of 5,000 is 500, and her 995 passes. With her 1,000 USDC the largest position she can open is about 9,900 USDC, since the fee rises with the size. Anything larger fails with `InitialMarginNotMet` (checked by `test_open_rejects_position_below_initial_margin`). The pool sets nothing aside for her, and the size of the pool does not limit the size of her position (`test_open_allowed_without_full_backing`). Whether the pool can pay her profit is decided when she closes, by the haircut described below. Her 5 USDC fee is split by `insurance_fee_bps`: half, 2.50, goes to `program_fees` for Maria and half to `insurance_fund`. The handler also records the slot the position opened in, `entry_slot`, for the profit warm-up. @@ -107,7 +107,7 @@ Bob opens his mirror-image short the same way: 1,000 collateral, 5,000 size, ent ![](figures/perps-open-short.svg) -Each open creates a **position** account at a PDA with seeds `"position"`, the pool, the owner, and the side (`"long"` or `"short"`), so a trader can hold at most one long and one short per market. The `Position` struct is a snapshot taken at open: `collateral` (net of the open fee), `size` (the notional exposure), `entry_price`, `size_scaled` (size divided by entry price, kept at `SIZE_PRECISION` fixed point), and `entry_funding` (the funding index at entry). Everything the program ever needs to settle the position is derivable from these fields plus the current price, which is what makes settlement **account-local**: closing or liquidating one position touches one position. The per-side scaled accumulator is what keeps the compute per transaction constant: each open adds size ÷ entry price (at `SIZE_PRECISION`) to its side. The pool's whole unrealized PnL is then two multiplications against the current price. +Each open creates a **position** account at a PDA with seeds `"position"`, the pool, the owner, and the side (`"long"` or `"short"`), so a trader can hold at most one long and one short per market. The `Position` struct is a snapshot taken at open: `collateral` (net of the open fee), `size` (the notional exposure), `entry_price`, `size_scaled` (size divided by entry price, kept at `SIZE_PRECISION` fixed point), and `entry_funding` (the funding index at entry). Everything the program ever needs to settle the position is derivable from these fields plus the current price, which is what makes settlement **account-local**: closing or liquidating one position touches one position. Each open adds its `size_scaled` to its side's accumulator. ## Funding Is a Fee the Heavier Side Pays @@ -120,7 +120,7 @@ The program makes two simplifications: Production venues scale the rate with the imbalance and pass each payment from one side straight to the other. They keep this program's bookkeeping: a pool-wide index, and the index value each position recorded when it opened. The test `test_funding_charged_to_long` opens a lone long, lets two thousand seconds pass, and asserts that the long paid size × rate × seconds ÷ `FUNDING_PRECISION` in funding, all of which went to `liquidity`. To keep every other number in this chapter clean, the walkthrough pool sets the funding rate to zero, as the test suite's default market does. -The rate is quoted per second of wall-clock time, and the index stores the Clock's timestamp at its last advance, whatever the network's slot length, as the *Time* chapter explains. The rate is fixed when the pool is created: `initialize_pool()` refuses one above `MAX_FUNDING_RATE_PER_SECOND`, 277, just under 0.1% of a position's size per hour, and no handler changes it afterwards. If Maria could change it, she could open a small position on the lighter side from any wallet, raise the rate, and close the position a few seconds later. The pool would pay her that funding out of the liquidity Carol and the other providers deposited. Everyone who opens a position or deposits liquidity has seen the rate, and `test_operator_on_the_lighter_side_earns_only_the_fixed_rate` checks that a wallet on the lighter side earns exactly that rate and no more. +The rate is quoted per second of wall-clock time, and the index stores the Clock's timestamp at its last advance, whatever the network's slot length, as the *Time* chapter explains. `initialize_pool()` refuses a rate above `MAX_FUNDING_RATE_PER_SECOND`, 277, just under 0.1% of a position's size per hour. If Maria could change the rate after creation, she could open a small position on the lighter side from any wallet, raise the rate, and close the position a few seconds later. The pool would pay her that funding out of the liquidity Carol and the other providers deposited. `test_operator_on_the_lighter_side_earns_only_the_fixed_rate` checks that a wallet on the lighter side earns exactly that rate and no more. ![](figures/perps-funding-index.svg) @@ -128,18 +128,18 @@ The rate is quoted per second of wall-clock time, and the index stores the Clock The quarter goes Alice's way: NVDAx climbs to $110. Mark both positions to market with the program's PnL formula: size × price change ÷ entry price. Alice is up 500. Bob is down 500, and Bob's **equity**, working collateral plus PnL minus funding owed, is 995 − 500 = 495 USDC. His maintenance margin is 250, so he is safe: `liquidate_position()` refuses a healthy position with `PositionHealthy`, which `test_healthy_position_cannot_be_liquidated` checks. -At $115, Bob's equity is 995 − 750 = 245, under the 250 maintenance line. He should close, but that means admitting he was wrong, and he keeps putting it off. Dave watches liquidations: he runs a bot that calls `liquidate_position()` on any position whose equity has fallen to or below maintenance margin, and he does it for the liquidation fee, not for the good of the market. Anyone may call `liquidate_position()`, and his bot does: the handler checks the position's health, not who is calling. The payout waterfall, every branch of which the program walks in order: +At $115, Bob's equity is 995 − 750 = 245, under the 250 maintenance line. He should close, but that means admitting he was wrong, and he keeps putting it off. Dave runs a bot that calls `liquidate_position()` on any position whose equity has fallen to or below the maintenance margin, for the liquidation fee. The handler checks the position's health, not who is calling. `liquidate_position()` splits Bob's equity in this order: - Bob's remaining equity, floored at zero: 245 USDC. - Dave takes the liquidation fee, 1% of the 5,000 notional = 50 USDC, capped at remaining equity: 50. - Bob is refunded the rest: 195 USDC, plus the rent from his position account, which closes. - The LPs absorb the difference between Bob's collateral and what was paid out: 995 − 245 = 750 USDC to `liquidity`. -A program runs only when someone sends it a transaction, so the design pays outsiders to send `liquidate_position()` at the right moment. Dave is the liquidator of the *Lending and Borrowing* chapter again: not a crank but a bot paid by the position it closes. +A program runs only when someone sends it a transaction, so `liquidate_position()` pays whoever sends it a fee from the position's equity. Unlike the lending market's liquidator, Dave puts up no capital: he is paid for sending the transaction. ![](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 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. +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 LPs have a 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. Every further move against the position adds to it. `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: > @@ -175,14 +175,14 @@ Here is where everyone ends up: - **Alice** turned 1,000 into 1,990 by being right with leverage. - **Bob** turned 1,000 into 195 by being wrong and slow. - **Dave** collected 50 USDC for one well-timed transaction. -- **Maria**'s `program_fees` holds 7.50, half of the 15 USDC charged on two opens and one close (Bob's liquidation charges no close fee); the other 7.50 is in `insurance_fund`, where it stays. She sweeps it with `collect_fees()`, which pays out what the fee ledger holds and sets it to zero. It is the only handler that needs her signature once the pool exists, and it refuses anyone else (`test_collect_fees_requires_authority`). -- **Carol** owns a twelfth of the pool, which absorbed Bob's 750 loss and paid Alice's 1,000 profit: down 250 on the trading, about 21 dollars of it hers. +- **Maria**'s `program_fees` holds 7.50, half of the 15 USDC charged on two opens and one close (Bob's liquidation charges no close fee); the other 7.50 is in `insurance_fund`, where it stays. She sweeps `program_fees` with `collect_fees()`, which pays her its balance and sets it to zero. It is the only handler that needs her signature once the pool exists, and it refuses anyone else (`test_collect_fees_requires_authority`). +- **Carol** owns a twelfth of the pool, which absorbed Bob's 750 loss and paid Alice's 1,000 profit: down 250 on the trading, about 21 USDC of it hers. ![](figures/perps-collect-fees.svg) -## The Math: Fees and the Maintenance Requirement Round Up, the Rest Floors or Truncates Toward Zero +## The Math: Fees, PnL, Funding and Pool Shares Round Against the User -Every calculation is checked integer arithmetic, u128 for amounts and i128 where a sign is needed (PnL, funding, equity), multiply before divide. `basis_points_of()` rounds fees and the maintenance requirement up, against the trader; only the insurance fund's cut of a collected fee rounds down (`basis_points_of_rounded_down()`). Share minting and withdrawal amounts floor against AUM, and the haircut rounds down; PnL truncates toward zero, which favors the trader on a loss and the pool on a profit; funding truncates toward zero too, which favors whichever side is paying. Prices arrive at the oracle's fixed-point scale (10^8 here) and cancel out of the PnL formula, so positions settle in collateral minor units. +Every calculation is checked integer arithmetic: u64 amounts widened to u128 for products, and i128 where a sign is needed (PnL, funding, equity), multiply before divide. `basis_points_of()` rounds fees and the maintenance requirement up, against the trader; only the insurance fund's cut of a collected fee rounds down (`basis_points_of_rounded_down()`). PnL floors, so a loss that is not a whole minor unit rounds up and a profit rounds down (`test_position_pnl_rounds_against_the_trader`). Funding rounds toward the pool: what a trader owes rounds up and what a trader receives rounds down (`test_position_funding_rounds_against_the_trader`). `add_liquidity()` values the pool rounding up and `remove_liquidity()` rounding down, and both then floor, so neither mints nor pays more than exact arithmetic would (`test_add_liquidity_values_the_pool_rounding_up`, `test_remove_liquidity_values_the_pool_rounding_down`). The haircut counts what traders are owed rounded up, then rounds the ratio and the payout down. Prices arrive at the oracle's fixed-point scale (10^8 here) and cancel out of the PnL formula, so positions settle in collateral minor units. ## The Invariants: Percolator's Guarantees, Simplified @@ -192,13 +192,13 @@ This program is a simplified model of three of Percolator's four guarantees, und - **Account-local safety**: the entry-snapshot fields on `Position`. - **Bounded progress**: the per-side accumulators and one-at-a-time liquidation. -The book's standing invariants hold here too. The vault balance is never less than `liquidity + total_collateral + program_fees + insurance_fund`, and equals it until someone donates to the vault; the tests' `assert_vault_matches_ledger` checks the equality. Share value cannot be moved by donating tokens to the vault, because AUM reads tracked state, not balances. Every position opened at or above the initial margin can be closed by `close_position()` once the oracle price is inside the band, or liquidated at any price once its equity is at or below the maintenance margin. +The book's standing invariants hold here too. The vault balance is never less than `liquidity + total_collateral + program_fees + insurance_fund`, and equals it until someone donates to the vault; the tests' `assert_vault_matches_ledger` checks the equality. Every position opened at or above the initial margin can be closed by `close_position()` once the oracle price is inside the band, after the warm-up if it is in profit. It can be liquidated at any price once its equity is at or below the maintenance margin. ## The Failure Modes: Leverage Amplifies Whatever You Got Wrong -- **A stale or manipulated price** is the catastrophic one: with 10x leverage, a 1% price error is a 10% equity error, market-wide, simultaneously. That explains the seven checks on the price feed and the band around the pool's own average price. The pool creator chooses the price feed, and whoever controls it sets the prices the pool settles at, held within the band for opens, closes and liquidity moves, and unbounded for liquidations. -- **A position gapped through zero**: the waterfall above (checked by `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity`). -- **First-depositor share inflation**: `liquidity` grows with every funding payment and trader loss, and a liquidity provider can also be the pool's only trader. An attacker holding a single share can pay funding into the pool from their own position to make that share expensive, then wait for a deposit to round down. The withheld `MINIMUM_LIQUIDITY` makes this cost them: the attacker's share is 1 of 1,001, and almost everything they pay in stays with the locked shares. In the test the attacker holds a long for ten simulated years at the highest funding rate a pool may have, pumping AUM to about 88,000 minor units. The victim deposits just under twice that, which mints the victim 2,001 shares against the attacker's 1. Dividing by the bare supply of 1 would have minted the victim a single share. `test_inflating_liquidity_through_own_trades_does_not_pay` runs the attack: the attacker pays about 87,000 minor units of funding and gets back 88. Divided by the bare supply instead, the same attack returns them about 132,500, about 44,000 of it from the victim's deposit. `test_first_deposit_below_minimum_fails` checks that a first deposit of 999 minor units, one under `MINIMUM_LIQUIDITY`, fails with `DepositTooSmall`, and that 1,001 mints a single share. +- **A stale or manipulated price**: with 10x leverage, a 1% price error is a 10% equity error in every open position at once. The seven price feed checks refuse a stale, malformed or uncertain price, and the band around the pool's average refuses a sudden jump. The pool creator chooses the price feed, and whoever controls it sets the prices the pool settles at, held within the band for opens, closes and liquidity moves, and unbounded for liquidations. +- **A position gapped through zero**: the insurance fund pays the deficit before the LPs do (checked by `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity`). +- **First-depositor share inflation**: `liquidity` grows with every funding payment and trader loss, and a liquidity provider can also be the pool's only trader. An attacker holding a single share can pay funding into the pool from their own position to make that share expensive, then wait for a deposit to round down. The withheld `MINIMUM_LIQUIDITY` makes this cost them: the attacker's share is 1 of 1,001, and almost everything they pay in stays with the locked shares. In the test the attacker holds a long for ten simulated years at the highest funding rate a pool may have, raising `liquidity` to about 88,000 minor units. The victim deposits just under twice that, which mints the victim 2,001 shares against the attacker's 1. Dividing by the bare supply of 1 would have minted the victim a single share. `test_inflating_liquidity_through_own_trades_does_not_pay` runs the attack: the attacker pays about 87,000 minor units of funding and gets back 88. Divided by the bare supply instead, the same attack returns them about 132,500, about 44,000 of it from the victim's deposit. `test_first_deposit_below_minimum_fails` checks that a first deposit of 999 minor units, one under `MINIMUM_LIQUIDITY`, fails with `DepositTooSmall`, and that 1,001 mints a single share. - **Winning more than the pool holds**: the haircut (checked by `test_haircut_scales_profit_when_pool_stressed`). - **Profit from a brief oracle spike**: refused until the position has been open for the warm-up (`ProfitNotMatured`). - **Sandwiching and price drift**: every trader and LP handler takes a user-supplied bound and fails with `SlippageExceeded` instead of filling at a surprise price. @@ -207,7 +207,7 @@ The book's standing invariants hold here too. The vault balance is never less th ## Testing Considerations -The suite's fifty tests share a mock oracle the tests set prices on, and a `Market` helper that builds the pool, warps slots, and re-derives position PDAs. Write one test per invariant and name it as the sentence it checks: `test_haircut_scales_profit_when_pool_stressed`, `test_remove_liquidity_capped_at_liquidity`, `test_healthy_position_cannot_be_liquidated`, `test_stale_price_rejected`, `test_collect_fees_requires_authority`. +The suite's fifty-four tests share a mock oracle the tests set prices on, and a `Market` helper that builds the pool, warps slots, and re-derives position PDAs. Write one test per invariant and name it as the sentence it checks: `test_haircut_scales_profit_when_pool_stressed`, `test_remove_liquidity_capped_at_liquidity`, `test_healthy_position_cannot_be_liquidated`, `test_stale_price_rejected`, `test_collect_fees_requires_authority`. The band tests set a price, move the clock, and assert the average to the minor unit: `test_single_update_moves_average_by_elapsed_fraction` checks that one read moves nothing and the next moves the average by exactly the elapsed share of the window. @@ -215,12 +215,12 @@ The band tests set a price, move the clock, and assert the average to the minor ## What You Learnt from Building the Perpetual Futures Venue -A traditional futures exchange is a matching engine, a clearing house with a margin department, a funding desk, and a floor of liquidation clerks. The pool is the clearing house and the counterparty, the two margins are its margin department, the insurance fund and the haircut are its default fund, the funding index is the funding desk, and a liquidator's bot is the liquidation clerk, paid per liquidation. +A traditional futures exchange is a matching engine plus a clearing house with a margin department and a default-management process. The pool is the clearing house and the counterparty, the two margins are its margin department, the insurance fund and the haircut are its default fund, and a liquidator's bot closes out a defaulted position, paid per liquidation. Funding has no traditional counterpart: it does the job that expiry does for a dated future. This chapter added seven concepts: - **Profit is a junior claim.** Pay profit through the haircut ratio, so the pool never pays out more than it holds. Delay profit by a warm-up so a brief false price cannot be cashed out. -- **An insurance fund before the LPs.** Route part of every fee into a fund that pays a bankrupt position's deficit first, and forgive a liquidation fee the position's equity cannot cover. +- **An insurance fund before the LPs.** Route part of every open and close fee into a fund that pays a bankrupt position's deficit first, and forgive a liquidation fee the position's equity cannot cover. - **Constant-time accounting.** Per-side scaled accumulators and per-position entry snapshots: Percolator's account-local-safety and bounded-progress guarantees, in miniature. - **Two margins.** Require an initial margin to open a position and a lower maintenance margin to keep it, so a new position has room to move before it can be liquidated. - **A price band around the program's own average.** Keep a time-weighted average of the oracle's price, credit each interval to the price seen at its start, and refuse trades at prices too far from the average, while letting liquidations through. diff --git a/building-financial-products-in-solana/prop-amm.md b/building-financial-products-in-solana/prop-amm.md index c2c681d7..08c5f272 100644 --- a/building-financial-products-in-solana/prop-amm.md +++ b/building-financial-products-in-solana/prop-amm.md @@ -1,23 +1,23 @@ # Chapter 16: The Prop AMM -The *Constant-Product AMM* chapter ended with the formula as the maker: one multiplication replacing the order book, at the cost of price impact and impermanent loss. This chapter builds the **proprietary AMM**, or prop AMM. A market-making firm funds a pool with its own capital and quotes both sides of it: anyone may buy the base token at the oracle price plus a spread, or sell it at the oracle price minus the spread. There is no curve, there are no liquidity providers, and there are no LP shares. The operator is the only capital in the market, which is the property the name comes from: "proprietary" trading is trading the firm's own capital. +The *Constant-Product AMM* chapter ended with the formula as the maker: one multiplication replacing the order book, at the cost of price impact and impermanent loss. This chapter builds the **proprietary AMM**, or prop AMM. A market-making firm funds a pool with its own capital and quotes both sides of it: anyone may buy the base token at the oracle price plus a margin, or sell it at the oracle price minus the same margin. There is no curve, there are no liquidity providers, and there are no LP shares. The operator is the only capital in the market, which is the property the name comes from: "proprietary" trading is trading the firm's own capital. Lifinity pioneered the model on Solana and wound down in December 2025, by which time the firms that followed it had taken most of its flow.[^lifinity-wind-down] By mid-2026 that volume went through BisonFi, HumidiFi, SolFi, ZeroFi, Tessera, and Obric, among the highest-volume venues on the network.[^prop-amm-share] You have probably never visited any of them, because they have no user interfaces. Their flow arrives through Jupiter, the aggregator that routes each swap to whichever venue quotes best, and a firm quoting live oracle prices with its own inventory usually quotes best. The program lives at `finance/prop-amm` in the companion repository. ## The Oracle Is the Price, the Spread Is the Fee -A constant-product pool computes its price from its reserves, which means its price is only right when arbitrageurs have made it right, and every trade pays price impact along the way. A prop AMM inverts that. The price comes from a price feed account, like the one in the *Offchain Truth* chapter. The operator's only pricing decision is the spread: buyers of the base token pay the ask, the oracle price plus `spread_bps`; sellers receive the bid, the oracle price minus it. The bid and ask from the *Order Book Exchange* chapter are back, except no one is resting orders; the firm *is* both sides of the book, permanently, at whatever the oracle says plus its margin. +A constant-product pool computes its price from its reserves, which means its price is only right when arbitrageurs have made it right, and every trade pays price impact along the way. A prop AMM inverts that. The price comes from a price feed account, like the one in the *Offchain Truth* chapter. The operator's only pricing decision is the spread: buyers of the base token pay the ask, the oracle price plus `spread_bps`; sellers receive the bid, the oracle price minus it. The bid and ask from the *Order Book Exchange* chapter are back, except no one is resting orders; the firm *is* both sides of the book, at the oracle price plus or minus its margin. Two things are absent as a result: - **No price impact.** A trade's unit price does not depend on its size; only the inventory limits it. -- **Nothing to sandwich.** The price does not depend on the pool's balances, and the sandwich attack from the *Adversary* chapter needs a price that moves with each trade. This one does not move. +- **Nothing to sandwich.** The price does not depend on the pool's balances, and the sandwich attack from the *Adversary* chapter needs a price that moves with each trade, and this one moves only when the oracle does. ## The Market Maker Opens the Market and Stocks It -Maria runs the market-making firm that operates the venue: she stocks it, sets the spread, and takes it off the market when she chooses. She signs with the standard wallet every character carries, and its token accounts hold her own 1,000 USDC and, from the firm's treasury, 1,000 NVDAx and 200,000 USDC. The market trades NVDAx, the NVIDIA stock the *Order Book Exchange* chapter traded, against USDC, and the oracle quotes NVDAx at $165. +Maria runs the market-making firm that operates the venue: she stocks it, sets the spread, and takes it off the market when she chooses. She signs with the standard wallet every character carries, and its token accounts hold her own 1,000 USDC and, from the firm's treasury, 1,000 NVDAx and 200,000 USDC. The market trades NVDAx, the NVIDIA stock, against USDC, and in this chapter the oracle quotes NVDAx at $165. -Maria calls `initialize_market()` with a spread of 10 basis points and a 1% cap on oracle confidence. The handler rejects a zero spread and a spread at or above 100% (`InvalidParameter` both ways). A zero spread earns nothing while every fill taken by a trader who knows where the price is going still loses, so it is refused as a mistake; at or above 100% the bid would be zero or below. +Maria calls `initialize_market()` with `spread_bps` at 10 basis points and a 1% cap on oracle confidence. `spread_bps` is a half-spread: the ask sits 10 basis points above the oracle price and the bid 10 below, so the spread between them is 20. The handler rejects a zero spread and a spread at or above 100% (`InvalidParameter` both ways). A zero spread earns nothing while every fill taken by a trader who knows where the price is going still loses, so it is refused as a mistake; at or above 100% the bid would be zero or below. ![](figures/prop-amm-open-market.svg) @@ -25,7 +25,7 @@ This one transaction created the three accounts the venue needs. The **market** account lives at a PDA with seeds `"market"` and the two mints: one market per pair, because the deployment *is* the firm; real prop AMMs are closed programs deployed by the firms themselves. The struct holds the operator's public key, the price feed, the program that owns it (`price_feed_program`, read from the price feed account's owner), and the price feed's scale, all fixed at creation. It also holds the two token decimals (recorded at creation so the math never trusts a passed-in mint), `spread_bps`, a confidence limit, and a `paused` flag. The market PDA is the authority of the **base vault** and **quote vault**, and the program signs for them with the market's seeds. -The market has no LP mint, because there are no depositors to issue receipts to. It has no fee ledger, because the spread it earns stays in the vaults as inventory. And it has no virtual reserves, because pricing never reads the reserves. The *Fundraiser* chapter's program counts contributions in its own `current_amount` field rather than reading the vault balance, because *other people* have claims against the vault that the balance alone cannot express. Here nobody but Maria has a claim on anything, so the vault balances are all the accounting the market needs. +The market has no LP mint, because there are no depositors to issue receipts to. It has no fee ledger, because the spread it earns stays in the vaults as inventory. And it has no virtual reserves, because pricing never reads the reserves. The *Fundraiser* chapter's program counts contributions in its own `current_amount` field rather than reading the vault balance, because a transfer straight into the vault moves its balance, and the target must count only accepted contributions. Here a donation only adds to Maria's inventory, so the vault balances are all the accounting the market needs. Then `deposit_inventory()` moves the 1,000 NVDAx and 200,000 USDC from the operator's token accounts into the vaults. No shares are minted, no deposit is recorded, and no `MINIMUM_LIQUIDITY` is withheld. Every deposit-side defense the *Constant-Product AMM* chapter needed exists to protect depositors from each other, and this market has one depositor. @@ -43,13 +43,13 @@ The fill is checked against her `minimum_amount_out`, and `InsufficientInventory ![](figures/prop-amm-alice-buy.svg) -Had a fund bought 500 NVDAx instead of Alice's 5, it would have paid $165.165 per token all the same. The *Constant-Product AMM* chapter's Bob paid an average of $2.46 on a $2.25 quote for moving the pool; Alice pays the quote. +Had a fund bought 500 NVDAx instead of Alice's 5, it would have paid $165.165 per token all the same. The *Constant-Product AMM* chapter's Bob paid an average of $2.46 on a $2.25 quote, because his own trade moved the pool's price; Alice pays the quote. ![](figures/prop-amm-curve-vs-quote.svg) ## A Seller Trades at the Bid, and the Round Trip Is the Fee -Bob holds 5 NVDAx alongside his standard wallet, and sells them: same instruction, direction `SellBase`. The bid is $165 minus 10 basis points, $164.835, **floored**, the mirror image of the ask's ceiling, and he receives exactly 824.175 USDC. +Bob needs USDC for a down payment and sells the 5 NVDAx he holds alongside his standard wallet: same instruction, direction `SellBase`. The bid is $165 minus 10 basis points, $164.835, **floored**, the mirror image of the ask's ceiling, and he receives exactly 824.175 USDC. ![](figures/prop-amm-bob-sell.svg) @@ -59,38 +59,40 @@ A round trip costs 1.65 USDC: buying 5 NVDAx cost 825.825 and selling them back ## Real Venues Price by Size, and the Ladder Is the Part the Firm Keeps to Itself -This program quotes one price per side for any size up to its inventory: one large bid and one large ask at the oracle price plus the spread. That is the simplest quote a prop AMM can make, and some venues run this shape. Most quote a **size ladder** instead. The first 50 NVDAx go at 5 basis points, the next 200 at 20, the next 750 at 50, so a taker who wants 300 pays the first two tiers in full and 50 tokens' worth of the third. A small buyer pays less than Alice did and a large one pays more, and the unit price rises with the size taken. +This program quotes one price per side for any size up to its inventory: one large bid and one large ask, `spread_bps` either side of the oracle price. That is the simplest quote a prop AMM can make, and some venues run this shape. Most quote a **size ladder** instead. The first 50 NVDAx go at 5 basis points, the next 200 at 20, the next 750 at 50, so a taker who wants 300 pays the first two tiers in full and 50 tokens' worth of the third. A small buyer pays less than Alice did and a large one pays more, and the unit price rises with the size taken. ![](figures/prop-amm-size-ladder.svg) -That looks like the constant-product pool's price impact, and it is not. The ladder never reads the vault balances. The firm sets it, and reprices it from its own inventory risk: a 300-token fill leaves Maria short 300 NVDAx that she must buy back somewhere else, and the ladder charges more for the sizes that cost more to hedge. The model that sets the tiers and reprices them is the part of a prop AMM the firm keeps to itself. It lives offchain, in the firm's own systems, with the firm's own view of the market; only the resulting ladder lands onchain, where the program's job is to fill against it and to refuse when the price feed is stale. This chapter's program keeps the single tier because the ladder adds arithmetic and no new mechanism: each tier is the quote math above, run once per tier, with the size that tier still has left. +That looks like the constant-product pool's price impact, and it is not. The ladder never reads the vault balances. The firm sets it, and reprices it from its own inventory risk: a 300-token fill takes 300 NVDAx out of the firm's inventory, which it must buy back somewhere else, and the ladder charges more for the sizes that cost more to hedge. The model that sets the tiers and reprices them is the part of a prop AMM the firm keeps to itself. It lives offchain, in the firm's own systems, with the firm's own view of the market; only the resulting ladder lands onchain, where the program's job is to fill against it and to refuse when the price feed is stale. This chapter's program keeps the single tier because the ladder adds arithmetic and no new mechanism: each tier is the quote math above, run once per tier, with the size that tier still has left. ## The Oracle Moves and the Quote Moves With It -If NVIDIA reported earnings and the price feed repriced to $170, the very next swap would fill at a $170.17 ask. No arbitrageur had to buy against a stale curve until the pool's ratio caught up, no LP inventory was sold below its value along the way. A constant-product pool only reaches a new price when traders arbitrage it there, and the profit those traders make comes out of the LPs; a prop AMM reads the new price instead. +If NVIDIA reported earnings and the price feed repriced to $170, the very next swap would fill at a $170.17 ask. A constant-product pool reaches a new price only when arbitrageurs trade it there, and their profit comes out of the LPs; a prop AMM reads the new price instead. Reading the price has its own cost, though. Between oracle updates, even fresh ones inside the 150-slot bound, the market is quoting a number that is seconds old, and anyone who knows where the price went in those seconds can buy below true value or sell above it. Market makers call this **adverse selection**: the loss a market maker takes when a trader who knows the price has moved fills a quote before it is updated. It is the cost the prop AMM carries in place of impermanent loss, and Maria bears all of it. ## The Market Maker Re-Quotes, and Sometimes Refuses to Quote -She handles adverse selection with `set_quote()`. When volatility rises, she widens the spread to 50 basis points (the next buyer pays $165.825 per NVDAx). When her own view of the market diverges from the price feed entirely, she sets `paused` and the market rejects every swap with `MarketPaused` until she returns. Watch the real venues during a fast market and you will see this: their quotes vanish from the aggregator and reappear minutes later, wider. +She handles adverse selection with `set_quote()`. When volatility rises, she widens the spread, setting `spread_bps` to 50 basis points (the next buyer pays $165.825 per NVDAx). When her own view of the market diverges from the price feed entirely, she sets `paused` and the market rejects every swap with `MarketPaused` until she calls `set_quote()` again with `paused` false. Watch the real venues during a fast market and you will see this: their quotes vanish from the aggregator and reappear minutes later, wider. ![](figures/prop-amm-set-quote.svg) The oracle checks in `swap()` pull the quote automatically, for the same reasons Maria pulls it by hand. For every other program in this book the staleness check protects users; for a market maker it protects the firm's own capital. -## The Firm Chooses Whom It Fills +## Real Venues Choose Whom They Fill -Maria can also handle it by choosing her counterparties. `swap()` fills any signer at the same quote, and no real venue does that. The teams that run prop AMMs come from block building and MEV, and they know that flow is not all alike. A retail swap routed through an aggregator is almost never informed: whoever sent it wants NVDAx, not an edge on the price. An arbitrage bot or a rival market maker hitting the quote the moment the price feed moves is informed by definition, since that is the only reason it trades. Adverse selection comes from the second kind, so the venues segment their flow: a tighter spread for flow they believe is uninformed, and a wider one, or none, for flow they believe is not. Jupiter built the signal for this into its Ultra router in 2025. A swap that came from Jupiter's own users is marked as such when it reaches the venue, and the prop AMMs quote tighter for it.[^ultra-signaling] +Real venues also handle adverse selection by choosing their counterparties. The teams that run prop AMMs come from block building and MEV, and they know that flow is not all alike. A retail swap routed through an aggregator is almost never informed: whoever sent it wants NVDAx, not an edge on the price. An arbitrage bot or a rival market maker hitting the quote the moment the price feed moves is informed by definition, since that is the only reason it trades. Adverse selection comes from the second kind, so the venues segment their flow: a tighter spread for flow they believe is uninformed, and a wider one, or none, for flow they believe is not. Jupiter built the signal for this into its Ultra router in 2025. A swap that came from Jupiter's own users is marked as such when it reaches the venue, and the prop AMMs quote tighter for it.[^ultra-signaling] -Some venues go further and keep a deny list of addresses they will not fill at all. That starts a cycle that does not end, because the refused firm moves to fresh wallets and the venue looks for the pattern behind them. +Some go further and keep a deny list of addresses they will not fill at all. That starts a cycle that does not end, because the refused firm moves to fresh wallets and the venue looks for the pattern behind them. -## The Market Maker Withdraws Everything in One Instruction +## The Market Maker Withdraws Everything, Then Closes the Market -When Maria wants her capital back, `withdraw_inventory()` hands her both vaults, to the last minor unit, in one instruction, with nobody's permission and none of the share arithmetic the constant-product pool's withdrawal needed. The market account survives, empty, refusing swaps with `InsufficientInventory`, and it holds nobody's tokens, so nothing is stuck in it. +When Maria wants her capital back, `withdraw_inventory()` hands her both vaults, to the last minor unit, in one instruction, with nobody's permission and none of the share arithmetic the constant-product pool's withdrawal needed. The market account survives, empty, refusing swaps with `InsufficientInventory`. ![](figures/prop-amm-withdraw-inventory.svg) +To shut the venue down, Maria then calls `close_market()`. It closes both vaults and the market account and returns to her the rent she paid for all three at `initialize_market()`. It refuses with `InventoryNotEmpty` while either vault holds any tokens, even tokens a stranger sent straight to a vault, so she withdraws first. Only the operator can call it. `test_close_market_returns_all_three_rents`, `test_close_market_refuses_while_a_vault_holds_tokens`, `test_close_market_refuses_tokens_sent_straight_to_a_vault` and `test_close_market_rejects_non_operator` check each of these. + ## The Math: Every Rounding Favors the Market The quote arithmetic is four small functions in `quote_math.rs`, and the invariant check is three more, all checked u128, multiply before divide, one rounding per quantity. The ask rounds **up**; the bid rounds **down**; both outputs floor. Every rounding direction favors the market, so the composite invariant holds: value out never exceeds value in, measured at the raw oracle price. The handler still asserts it after computing each fill, cross-multiplied so the check itself does no rounding, and fails with `InvariantViolated` if a future refactor breaks a rounding direction. Assert after the math, as the constant-product pool's post-trade K check does, here checking a much simpler formula. @@ -106,14 +108,14 @@ The unit conversion carries three exponents. Prices arrive at the oracle's fixed ## The Failure Modes: What Attacks a Market Maker -- **The stale quote** is the catastrophic failure, and it needs no exploit: an oracle that lags a moving market is enough. Every fill against a wrong quote is a risk-free profit for the counterparty and a loss for Maria; `StalePrice` and `OracleConfidenceTooWide` close the path (`test_swap_rejects_stale_price`, `test_swap_rejects_wide_confidence`). The *Perpetual Futures* chapter meets the same failure with leverage multiplying it. -- **Adverse selection** (above). No `require!` can refuse "traders who know something", so Maria handles it commercially: a spread wide enough to pay for it, a ladder that charges for size, quotes pulled when prices are moving fast, and flow segmented by where it came from. The `paused` flag is a risk control Maria expects to use. +- **The stale quote** is the catastrophic failure, and it needs no exploit: an oracle that lags a moving market is enough. Every fill against a wrong quote is a risk-free profit for the counterparty and a loss for Maria; `StalePrice` and `OracleConfidenceTooWide` refuse those swaps (`test_swap_rejects_stale_price`, `test_swap_rejects_wide_confidence`). The *Perpetual Futures* chapter meets the same failure with leverage multiplying it. +- **Adverse selection** (above). No `require!` can refuse "traders who know something", so it is handled commercially. Maria widens the spread and pauses with `set_quote()`; real venues add a size ladder and segment their flow. The `paused` flag is a risk control Maria expects to use. - **The operator chooses which oracle to trust.** The operator picks the price feed at creation, and `PriceFeedNotFromOracle` holds the market to the program that owned it then (`test_swap_rejects_price_feed_from_another_program`). Whether that program publishes good prices is Maria's decision, the same visible trust decision the *Perpetual Futures* chapter's pool creator makes. -- **What is absent.** Run the *Adversary* chapter's checklist against this program and whole categories do not apply. No inflation attack: there are no shares, so there is no share price for a first depositor to skew. No sandwich: no price impact to squeeze. No vacuous-invariant drain on an empty reserve: an empty vault rejects with `InsufficientInventory`. Each of those attacks needs a mechanism this program does not have. +- **What is absent.** Of the attacks the *Adversary* chapter covers, two do not apply here. No inflation attack: there are no shares, so there is no share price for a first depositor to skew. No sandwich (above). Nor does the *Constant-Product AMM* chapter's empty-reserve drain: an empty vault rejects with `InsufficientInventory`. Each of those attacks needs a mechanism this program does not have. ## Testing Considerations -The suite (`tests/test_prop_amm.rs`) checks the quote math to the minor unit in both directions: `test_swap_buys_base_at_the_ask` and `test_swap_sells_base_at_the_bid` assert Alice's and Bob's exact numbers, and `test_round_trip_costs_exactly_the_spread` asserts the 1.65 USDC that makes the spread the fee. `test_quote_follows_the_oracle` and `test_set_quote_changes_the_spread` cover the two ways the quote moves. The rejection tests cover every refusal: a price feed account owned by another program, stale price, a price from before a restart, wide confidence, paused market, slippage, zero amounts, inventory bounds, and the three operator-only handlers against an imposter. And `test_operator_can_withdraw_everything_and_swaps_then_fail` checks that Maria can withdraw all of the inventory and that a swap afterwards fails: the capital is Maria's, and without it the market refuses every swap. +The suite (`tests/test_prop_amm.rs`) checks the quote math to the minor unit in both directions: `test_swap_buys_base_at_the_ask` and `test_swap_sells_base_at_the_bid` assert Alice's and Bob's exact numbers, and `test_round_trip_costs_exactly_the_spread` asserts the 1.65 USDC that makes the spread the fee. `test_quote_follows_the_oracle` and `test_set_quote_changes_the_spread` cover the two ways the quote moves. The price rejection tests cover a price feed account owned by another program, stale price, a price from before a restart, wide confidence, a price at or below zero, a price feed at another scale, and a price feed account too short to decode. The rest cover paused market, slippage, zero amounts, an output that rounds to zero, inventory bounds, spread bounds, closing a stocked market, and the four operator-only handlers against an imposter. And `test_operator_can_withdraw_everything_and_swaps_then_fail` checks that Maria can withdraw all of the inventory and that a swap afterwards fails: the capital is Maria's, and without it the market refuses every swap. ## What You Learnt from Building the Prop AMM @@ -122,8 +124,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, 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. +- **Adverse selection**, the loss a market maker takes to traders who know the price has moved, and the commercial answers to it: a wider spread, a pause, and on real venues a size ladder and segmented flow. +- **When the vault balances can be 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 fb2e8098..ab6e84cd 100644 --- a/conclusion/where-to-go-from-here.md +++ b/conclusion/where-to-go-from-here.md @@ -1,6 +1,6 @@ # Chapter 21: Where to Go From Here -The Introduction promised settlement that is atomic and global, one set of accounts every party reads, fees that ignore the amount, markets that never close, rules that run as instruction handlers, assets of every kind, and distribution the moment a program is deployed. Each of those is now a tool you have built with, and what is left is choosing what to build next. +The Introduction promised settlement that is atomic and global, one set of accounts every party reads, fees that ignore the amount, markets that never close, rules that run as instruction handlers, assets of every kind, and distribution the moment a program is deployed. You have now built programs that rely on each of those, and what is left is choosing what to build next. ## What You Can Build Now @@ -19,7 +19,7 @@ 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 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. +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. 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. @@ -27,7 +27,7 @@ You can take responsibility for a program once it holds other people's assets: t Look away from the chain for a moment, at what the people around you deal with every week. -A café hands a percentage of every sale to the card networks and processors, and on a four dollar coffee the fixed part of that fee often costs more than the percentage does. Someone sending money to family abroad pays a rate set by which two countries they happen to be standing in, which in some corridors is more than 20%.[^remittance-corridors] Money in a savings account earns close to nothing while the same money is lent out at several times that, and the bank keeps the difference. And the payment behind each of them settles only on a banking day: a transfer sent on Friday evening arrives on Monday, or on Tuesday after a public holiday. +A café hands a percentage of every sale to the card networks and processors, and on a four dollar coffee the fixed part of that fee often costs more than the percentage does. Someone sending money to family abroad pays a rate set by which two countries they happen to be standing in, which in some corridors is more than 20%.[^remittance-corridors] A savings deposit earns close to nothing while the bank lends it out at a higher rate and keeps the difference. And the payment behind each of them settles only on a banking day: a transfer sent on Friday evening arrives on Monday, or on Tuesday after a public holiday. Every one of these has the same structure: parties standing between two people who have business with each other, each taking a cut and adding a delay, and a set of hours during which the arrangement operates at all. A program does not remove the need for underwriting, and it does not make a stranger's credit risk disappear. What it removes is the intermediary whose job was holding the funds and keeping the record, which a vault and an account already do, for a fraction of a cent, at three in the morning on a Sunday. @@ -38,68 +38,66 @@ The café owner, the sender, and the saver do not want a blockchain. They want t So aim at consumers and businesses rather than at a crypto audience. In practice that means: - Quote everything in dollars and in time, whether it is a rate, a limit, or a settlement time, never in basis points, token symbols, or pool utilization. -- Keep private keys and signing behind the product. Someone should be able to use it having never downloaded wallet software or held a token deliberately, and never learn that they now do. +- Keep private keys and signing behind the product. Someone should be able to use it having never downloaded wallet software or held a token deliberately, and never learn that they now hold one. - Put the benefit on the front page in the user's own words: "get paid the day you invoice", "spend without selling your shares", "nobody can walk off with the pot". - Say the risk in the same plain words. Somebody spending against their shares needs liquidation explained on the first screen, and a rate that comes from borrowers can fall when borrowing does. ## Products That Remove a Cost or a Delay -**An account that pays like a market and still handles traditional wires.** A business keeping its balances in USDC settles with anyone else inside the system in seconds, at any hour, and earns a rate that comes from what borrowers pay rather than from what a bank has left over. It still needs an IBAN for the supplier who can only receive a bank transfer, SWIFT for the ones abroad, and local rails for payroll. The product moves at the speed of the legacy system where it has to, and settles instantly everywhere else. The rate is the *Lending and Borrowing* chapter and the pooled accounting is the *Managed Fund* chapter. The hard parts are not in the program: banking partners, the licenses that let dollars cross between the two systems, and the checks a bank is required to run on both sides of every transfer. +**An account that pays like a market and still handles traditional wires.** A business keeping its balances in USDC settles with anyone else inside the system in seconds, at any hour, and earns a rate that comes from what borrowers pay rather than from what a bank has left over. It still needs an IBAN for the supplier who can only receive a bank transfer, SWIFT for the ones abroad, and local rails for payroll. The product moves at the speed of the legacy system where it has to, and settles instantly everywhere else. The rate comes from the lending market the *Lending and Borrowing* chapter builds, and the pooled accounting from the *Managed Fund* chapter. The hard parts are not in the program: banking partners, the licenses that let dollars cross between the two systems, and the checks a bank is required to run on both sides of every transfer. -**Savings that pay what borrowers pay.** The deposit side of the *Lending and Borrowing* chapter, with a balance, a rate, and a withdraw button in front of it and nothing else on the screen. The versions that succeed are the ones that state the awkward part first. Deposits earn interest because they are lent out, so when most of the pool is on loan, the cash left in the reserve may not cover a withdrawal until borrowers repay. A depositor should read that on the screen before they deposit, not learn it from a refused withdrawal. +**Savings that earn what borrowers pay, less the market's cut.** The deposit side of the *Lending and Borrowing* chapter, with a balance, a rate, and a withdraw button in front of it and nothing else on the screen. State first that deposits earn interest because they are lent out, so when most of the pool is on loan, the cash left in the reserve may not cover a withdrawal until borrowers repay. A depositor should read that on the screen before they deposit, not learn it from a refused withdrawal. -**Spending against your shares instead of selling them.** Someone holding NVDAx who needs two thousand dollars can sell, ending a position they wanted to keep and in many countries creating a tax bill, or borrow against it and keep it. The second option is the *Lending and Borrowing* chapter in full: shares as collateral, a price from a price feed as covered in the *Offchain Truth* chapter, and a liquidation path when the collateral falls. Around it sits a card, a spending limit, and a warning that arrives long before a liquidator does. +**Spending against your shares instead of selling them.** Someone holding NVDAx who needs two thousand dollars can sell, ending a position they wanted to keep and in many countries creating a tax bill, or borrow against it and keep it. The second option uses the whole lending market the *Lending and Borrowing* chapter builds: shares as collateral, a price from a price feed as covered in the *Offchain Truth* chapter, and a liquidation path when the collateral falls. Around it sits a card, a spending limit, and a warning that arrives long before a liquidator does. -**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 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 loses their stake to the ones who did not. The *Betting Market* chapter's parimutuel pool settles one winning outcome per event, so extend it to settle several at once: 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. -**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 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. People in many countries run one, under a different name in each. 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 can hold the pot and run the rotation, but it cannot make a member who has already been paid keep contributing. That still needs collateral, or the social pressure the organizer supplied. 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. +**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. Held in a vault that neither party controls, the deposit can earn interest for the tenant instead, and still be released to either side when the lease ends. Design the state where the two sides disagree about damage first, and give it a handler that releases the deposit, so it cannot be stuck there. -**Money raised from the people who already buy from you.** A roaster who needs eighty thousand dollars for a second location can raise it from the customers who drink the coffee: all-or-nothing against a deadline, refunds if the target is missed, repayment as a share of revenue. The raise is the *Fundraiser* chapter almost unchanged, and the revenue share is new work. What it displaces is a chain of intermediaries, each with a minimum deal size below which it will not take the work. +**Money raised from the people who already buy from you.** A roaster who needs eighty thousand dollars for a second location can raise it from the customers who drink the coffee: all-or-nothing against a deadline, refunds if the target is missed, repayment as a share of revenue. The *Fundraiser* chapter's program handles the raise almost unchanged, and the revenue share is new work. It displaces the bank and the lender repaid from a share of revenue, each with a minimum deal size below which it will not take the work. -**Cash today for an invoice due in sixty days.** A supplier ships forty thousand dollars of goods on sixty-day terms and needs the money this week, so funders advance most of the face value and are repaid when the buyer pays. Pooling and interest come from the *Lending and Borrowing* chapter, repayment runs through escrow, and whether the invoice was paid is an offchain fact, which makes the *Offchain Truth* chapter the one this product depends on most. The credit risk stays where it always was, on the buyer. What goes away is the underwriting time and the size floor. +**Cash today for an invoice due in sixty days.** A supplier ships forty thousand dollars of goods on sixty-day terms and needs the money this week, so funders advance most of the face value and are repaid when the buyer pays. Pooling and interest come from the *Lending and Borrowing* chapter, repayment runs through escrow, and whether the invoice was paid is an offchain fact, which makes the *Offchain Truth* chapter the one this product depends on most. The credit risk stays where it always was, on the buyer. What goes away is the size floor: a program costs the same to run for a 4,000-dollar invoice as a 400,000-dollar one. -**Pay that arrives the day the work is signed off.** A contractor in another country is usually paid when the next batch runs, and the cross-border transfer can take a large percentage of their pay before it arrives. Paid on approval, for a flat fee of a fraction of a cent, they receive their pay sooner and keep nearly all of it. Streaming payments are a small build: the state machine has few states and the amount released grows in proportion to the seconds elapsed. +**Pay that arrives the day the work is signed off.** A contractor in another country is usually paid when the next batch runs, and the cross-border transfer can take a large percentage of their pay before it arrives. Paid on approval, for a flat fee of a fraction of a cent, they receive their pay sooner and keep nearly all of it. The client funds a vault when the work is agreed, as the *Escrow* chapter's maker does, and a handler the client signs releases it to the contractor. -**Payouts with no claim to file.** A flight lands three hours late; a region gets no rain for a month. Where the fact is measurable and comes from a source both sides agreed on in advance, the payout is a rule instead of a claims department: a pool that pays a fixed amount when the measurement crosses a line. Pool accounting from the *Betting Market* chapter, measurement from the *Offchain Truth* chapter. This is insurance, and regulators classify a product by what it does rather than by what it is built on. +**Payouts with no claim to file.** A flight lands three hours late; a region gets no rain for a month. Where the fact is measurable and comes from a source both sides agreed on in advance, the payout is a rule instead of a claims department: a pool that pays a fixed amount when the measurement crosses a line. Build the pool from the *Betting Market* chapter's accounting and take the measurement as the *Offchain Truth* chapter describes. This is insurance, and regulators classify a product by what it does rather than by what it is built on. -**Company cash that can move over the weekend.** Treasury cash that cannot be moved or redeemed from Friday evening to Monday morning can be held in a managed fund instead, redeemable at a share price at any hour. The *Managed Fund* chapter builds that program, with a manager who may change the allocation and a curator who limits what it may hold, and the *Operating Financial Software* chapter sets the limits on an operator's power. +**Company cash that can move over the weekend.** Treasury cash that cannot be moved or redeemed from Friday evening to Monday morning can be held in a managed fund instead, and withdrawn at any hour as the holder's share of each asset the fund holds. The *Managed Fund* chapter builds that program, with a manager who may change the allocation and a curator who limits what it may hold, and the *Operating Financial Software* chapter sets the limits on an operator's power. ## The Problems Nobody Has Solved Yet -The better ideas are in the places where the programs exist and no product has shipped. +Two problems remain open: one where the program exists and no product has shipped, and one where no program can do the job alone. -**Rent that rises without you.** Rent tracks the housing market and a renter's savings do not, so every increase is a loss with no offsetting gain. Put a monthly amount into an asset that moves with the same market, and the asset's gain covers part of the next rise. The *Managed Fund* chapter's program already does this: it takes deposits, issues shares at a share price, and holds a basket against target weights, priced as the *Offchain Truth* chapter describes. One city's rents and any tradable index of housing move together only loosely, and a renter told their rent is covered when it might not be is worse off than one never offered the product. The work is in the disclosure and the product design: saying how much of the next rise the asset is likely to cover, and how much it might not. +**Savings that rise with your rent.** Rent tracks the housing market and a renter's savings do not, so every increase is a loss with no offsetting gain. Put a monthly amount into an asset that moves with the same market, and the asset's gain covers part of the next rise. The *Managed Fund* chapter's program has the parts: it takes deposits, issues shares at a share price, and holds a basket against target weights, priced as the *Offchain Truth* chapter describes. One city's rents and any tradable index of housing move together only loosely, and a renter told their rent is covered when it might not be is worse off than one never offered the product. The work is in the disclosure and the product design: saying how much of the next rise the asset is likely to cover, and how much it might not. **Credit for people with no collateral to post.** The one lending program in this book lends only against collateral worth more than the loan, because that is the only kind of credit a program can enforce by itself. The credit most people need is the other kind, and the things that make it work, an income record, a reputation, somewhere to take a defaulter, all live offchain. ## What This Book Left Out -Some things were left out on purpose. - **The interface** was left out because it is ordinary application development. Nothing in these pages covers the screen someone taps, or the wallet connection, signing, and transaction building behind it, and all of that has libraries, examples, and plenty of people who already do it well. A good interface is what makes people adopt a financial product and keep using it, so give it as much care as the program. The program is the part this book can teach, and it has built ten of them. **Ecosystem projects** already do several jobs a financial product needs that this book did not build. There are projects for proof of personhood, multisig wallets to hold upgrade and admin authority, services that convert between bank deposits and stablecoins, and crank turners that call your maintenance handlers on a schedule. Each has its own coding conventions and technology, so learn each one from its own documentation. The *Fundraiser* chapter covers why a product would check personhood, the *Operating Financial Software* chapter why upgrade authority belongs to a multisig, and the *Lending and Borrowing* chapter what a crank does. **Reward distribution** got no chapter of its own, because the book already builds it twice. The accumulation factor in the *Lending and Borrowing* chapter and the proportional payout in the *Betting Market* chapter are the same accounting that pays liquidity incentives, revenue share to token holders, or a return for locking a token up. -**Vesting and streaming payments** have no chapter of their own, though the pay-on-approval product above is a streaming payment. +**Vesting and streaming payments** have no chapter of their own. **Governance** is absent. Most programs here have an operator with a private key, and admin power is explained in the *Operating Financial Software* chapter. Replacing that private key with a voting process is a separate design problem. -The book keeps changing after you close it. New chapters, corrections, and editions are announced at @QuicknodeSolana on X, which is also the fastest way to ask a question or suggest what the next edition should cover.[^follow-quicknode-solana] - ## Build One, Then Have It Audited -One project separates reading this book from shipping, and it takes less time than you expect. Pick the smallest product you want to exist, for a person you can name. Draw its state machine before you write a handler. Make sure every participant can get their assets out of every state the program can stop in. Write the tests that drive the machine down every path, especially the ones that pay tokens out, and the invariant tests that assert what must never change. +Pick the smallest product you want to exist, for a person you can name. Draw its state machine before you write a handler. Make sure every participant can get their assets out of every state the program can stop in. Write the tests that drive the machine down every path, especially the ones that pay tokens out, and the invariant tests that assert what must never change. -Then get a second (and third) opinion from an expert who did not work on it. +Then have it audited by at least one reviewer who did not work on it. -Find out how it holds up on devnet, and when you and your third-party auditors are satisfied, deploy to mainnet. +Find out how it holds up on devnet, and when you and your reviewers are satisfied, deploy to mainnet. Find someone whose money is stuck behind a weekend, or losing a percentage to fees nobody asked them to agree to, and build the financial app they would use without ever wondering what it runs on. +New chapters, corrections, and editions are announced at @QuicknodeSolana on X, which is also the fastest way to ask a question or suggest what the next edition should cover.[^follow-quicknode-solana] + [^remittance-corridors]: The Introduction's note on remittance costs gives how many corridors charge this much, and the World Bank source for them. diff --git a/financial-programming-concepts-in-solana/figures/inflation-attack.svg b/financial-programming-concepts-in-solana/figures/inflation-attack.svg index 237812f5..fca0cc42 100644 --- a/financial-programming-concepts-in-solana/figures/inflation-attack.svg +++ b/financial-programming-concepts-in-solana/figures/inflation-attack.svg @@ -1,4 +1,4 @@ - + An attacker makes Bob's deposit buy no shares @@ -23,5 +23,4 @@ 2,000.000001 USDC in the vault the attacker's one share redeems for all 2,000.000001 USDC - Bob's shares = floor(deposit × shares outstanding ÷ pool value) = floor(1,000 × 1 ÷ 1,000.000001) = 0 diff --git a/financial-programming-concepts-in-solana/intro.md b/financial-programming-concepts-in-solana/intro.md index 4690334a..a14c49fe 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 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. +Every financial program shares one shape: a state machine. ## Financial Software Is a State Machine @@ -13,17 +13,17 @@ The products in this book each move through a few states: Before writing any handler, draw this machine: name the states, name every legal transition, and check that nothing is missing. -The state drawing gives the design rules: +Then design the handlers from it: - **Give each transition its own instruction handler.** `take_offer()` moves an offer from open to taken and performs no second transition. Handlers that perform two transitions, or half of one, create bugs. - **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. +- **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. - **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 -Whoever wants the account pays its deposit: the maker funds the offer account and the vault, and the taker funds their own token account if it does not exist yet. Closing the account refunds the deposit, as the *Core Building Blocks of Solana* chapter explains, and an account left open keeps someone else's lamports locked, so for every account your program creates, name the handler that closes it. The escrow program you will build in this book has tests asserting that the maker gets back exactly the lamports they paid, and yours should too. +Whoever wants the account pays its deposit: the maker funds the offer account and the vault, and the taker funds their own token account if it does not exist yet. Closing the account refunds the deposit, as the *Core Building Blocks of Solana* chapter explains, and an account left open keeps the payer's rent deposit in it, so for every account your program creates, name the handler that closes it. The escrow program you will build in this book has tests asserting that the maker gets back exactly the lamports they paid, and yours should too. ![](figures/rent-lifecycle.svg) diff --git a/financial-programming-concepts-in-solana/offchain-truth.md b/financial-programming-concepts-in-solana/offchain-truth.md index 1d1d8123..9503bf68 100644 --- a/financial-programming-concepts-in-solana/offchain-truth.md +++ b/financial-programming-concepts-in-solana/offchain-truth.md @@ -10,34 +10,34 @@ The first is **signed straight to you**: the trusted party signs the transaction The second is **published to an account**: the trusted party signs its statement offchain with their private key, an oracle program verifies that signature onchain and records the fact in an account it owns, and your program reads that account in a later transaction. Pyth Core and Chainlink both work this way, and your own program verifies no signature at all. The signature was checked already, in somebody else's transaction. What your program establishes is where the account came from. -The third is **signed offchain and carried in your transaction**: the trusted party signs its statement offchain with their private key, and the client fetches the signed statement and passes it to your program as an instruction argument. The signature is checked in that same transaction, by your program or by an oracle program it calls. Pyth Pro works this way. Nothing is stored, and the fact is used in the instruction that verified it. The client chooses which signed statement to submit, so it can submit an old one, and the freshness check below is what stops it. +The third is **signed offchain and carried in your transaction**: the trusted party signs its statement offchain with their private key, and the client fetches the signed statement and passes it to your program as an instruction argument. The signature is checked in that same transaction: Solana's built-in ed25519 program checks it in an instruction placed before yours, and your program, or an oracle program it calls, checks that this instruction covered the message. Pyth Pro works this way. Nothing is stored, and the fact is used in the instruction that verified it. The client chooses which signed statement to submit, so it can submit an old one, and the freshness check below is what stops it. Whichever the shape, by the time your program sees the fact it is an ordinary instruction argument or ordinary account data, and it can be held to the same standard as every other input: -- **The provenance.** Is this the exact account the program was configured with, owned by the program whose verification you are relying on? A fact from an unexpected account is rejected. Anchor's `Account` enforces the owner and the discriminator (the type tag at the start of an account's data), and the price feed's own identity is a stored-pubkey comparison on top of that. The managed fund rejects any price account that is not the price feed its asset config copied from the curator's `ApprovedAsset` entry when the asset was added. The perpetual futures venue and the prop AMM record the program that owns their price feed account at creation and refuse a price feed account owned by any other program (`PriceFeedNotFromOracle`), before decoding a byte of it. In the companion repository the lending market, the perpetual futures venue and the prop AMM each read a test stand-in, and the managed fund reads the Pyth layout itself. The lending market's stand-in is written by `set_price()`, which only a transaction signed with the market owner's private key may run. In production each would read a Pyth `PriceUpdateV2` account, the second shape. -- **The freshness.** A true price from five minutes ago can be a dangerously wrong price now. Every consumer states its tolerance and rejects anything older: the lending market refuses prices more than 25 slots old, the managed fund more than 60 seconds. Which clock a tolerance is stated in, and why the answer is usually slots, is the *Time* chapter's subject. +- **The provenance.** Is this the exact account the program was configured with, owned by the program whose verification you are relying on? A fact from an unexpected account is rejected. Anchor's `Account` enforces the owner and the discriminator (the type tag at the start of an account's data), and on top of that the program compares the price feed account's address with a public key it stored. The managed fund rejects any price account that is not the price feed its asset config copied from the curator's `ApprovedAsset` entry when the asset was added. The perpetual futures venue and the prop AMM record the program that owns their price feed account at creation and refuse a price feed account owned by any other program (`PriceFeedNotFromOracle`), before decoding a byte of it. In the companion repository the lending market, the perpetual futures venue and the prop AMM each read a test stand-in, and the managed fund reads the Pyth layout itself. The lending market's stand-in is written by `set_price()`, which only a transaction signed with the market owner's private key may run. In production each would read a Pyth `PriceUpdateV2` account, the second shape. +- **The freshness.** A true price from five minutes ago can be a dangerously wrong price now. Each of the book's four programs that read a price refuses one older than its own window; the *Time* chapter lists the four windows and why most are counted in slots. - **The confidence.** Good price feeds publish their own uncertainty: $250 ± $0.10 is a different fact from $250 ± $8, and the second one, during a volatile minute, may not be safe to liquidate against. The managed fund refuses a price whose confidence interval is wider than 1% of the price (`OracleConfidenceTooWide`), and the lending market, the perpetual futures venue and the prop AMM each refuse one wider than their own `max_confidence_bps`, set per reserve, pool, or market. A fresh price with a wide confidence interval is a separate failure mode from a stale one. - **The degenerate values.** A price of zero or below means a broken price feed, and the program must fail before that number multiplies against anything. Negative prices are real but exceptionally rare, and never for a token.[^negative-oil] -> **How a Pyth price reaches your program.** **Pyth Core** is signed by a multisig of five private keys that Pyth holds.[^pyth-guardians] Anyone can post a signed update to Solana, where the **Pyth Receiver program** verifies those signatures and writes a `PriceUpdateV2` account that it owns. Your program checks the account's `feed_id`, `publish_time`, and `conf`, plus `verification_level`, which records whether the full signer set signed or a subset, so a partially verified update is a weaker fact than a fully verified one. The companion repository's Pyth example (`basics/pyth`) checks the age from `publish_time` and reads `feed_id`, `conf` and `price`; the managed fund reads the same layout at fixed byte offsets, with no crate between it and the account. +> **How a Pyth price reaches your program.** **Pyth Core** is signed by a multisig of five private keys that Pyth holds.[^pyth-guardians] Anyone can post a signed update to Solana, where the **Pyth Receiver program** verifies those signatures and writes a `PriceUpdateV2` account that it owns. Your program checks the account's `feed_id`, `publish_time`, and `conf`, plus `verification_level`. That field records whether a quorum of the signers (three of the five) signed, or fewer, so a partially verified update is a weaker fact than a fully verified one. The companion repository's Pyth example (`basics/pyth`) checks the age from `publish_time` and reads `feed_id`, `conf` and `price`. The managed fund reads the same layout at fixed byte offsets, with no crate between it and the account, and refuses any update that is not fully verified (`PriceNotFullyVerified`). > > **Pyth Pro**, the paid low-latency product, streams its prices to your client, which receives each update as one signed message: the price, its confidence and timestamp, Pyth's signature made with a single private key, and the matching public key. The client then builds a transaction with two instructions. The first goes to Solana's built-in ed25519 program, which checks the signature over the message. The second goes to your program, with the signed message in its instruction data. Your program passes the message to the Pyth Pro verifier program, which checks that the ed25519 instruction covered exactly these bytes and that the public key is one Pyth has registered as trusted. It hands the price back for your program to check and use; changing one byte of the message breaks the signature, so the client that carried it cannot alter it. At most two public keys are registered at once, each with an expiry, so Pyth can replace one. ## Choose Who Signs by Asking What Lying Costs Them and What Happens When They Stop -Who signs the fact is a design decision with a range of answers, and the way to make it is to ask three questions: who publishes, what does it cost them to lie, and what happens when they stop publishing? +To choose who signs the fact, ask three questions: who publishes, what does it cost them to lie, and what happens when they stop publishing? -At one end is the **single trusted signer**. The betting market's admin settles the event with one signature: `settle_event()` writes their answer to the event account's `winning_outcome_index`, and every payout follows from it. For a small market whose operator's business is its reputation, this can be the right design, and the program's job is to make the trust visible and narrow. The admin picks the winning outcome and can do nothing else with that signature: not change the payout arithmetic, not take more than the fee fixed at creation, not reach the vault by any other path, as the *Betting Market* chapter shows. +At one end is the **single trusted signer**. The betting market's admin settles the event with one signature: `settle_event()` writes their answer to the event account's `winning_outcome_index`, and every payout follows from it. For a small market whose operator's business is its reputation, this can be the right design if the program limits what that signature can do. The admin picks the winning outcome and can do nothing else with that signature: not change the payout arithmetic, not take more than the fee fixed at creation, not reach the vault by any other path, as the *Betting Market* chapter shows. In the middle are **committees and multi-party resolution**: several parties must agree before a fact is recorded, so lying requires collusion and any single defector breaks the lie. The drawback is coordination and latency. At the far end are **price feed networks** like Pyth, which aggregate many independent publishers (exchanges, market makers) into a single signed publication with a confidence interval, updated continuously. Lying requires bribing a meaningful share of publishers whose businesses depend on being right, which is the strongest economic answer available to "what does it cost them to lie." The publishers' signatures are checked offchain, where the network combines them; what reaches the chain is signed with the network's own private keys, three of five for Pyth Core and one for Pyth Pro. A program that reads Pyth trusts Pyth to keep those private keys safe as well as trusting its publishers, and a single stolen Pro private key could sign a false price until Pyth removes its public key. This is the production path for the lending market, the perpetual futures venue and the prop AMM. The checks in the previous section apply whichever network a program reads. -The third question, **what happens when publication stops**, needs an answer in the program's state machine. Price feeds go stale during outages, and committees deadlock. A program in which every way to withdraw needs a fresh fact strands everyone's funds when the source stops publishing. The staleness checks above stop the program acting on an old fact. The state machine has to cover the other case: every participant must be able to get their assets out of every state without a new fact arriving. The betting market's admin can call `cancel_event()`, after which each bettor takes their stake back with `claim_refund()`, without an outcome ever being declared. The lending market's `redeem_reserve_collateral()` reads no price feed, so suppliers can leave while the oracle is silent, and a borrower with no debt can withdraw their collateral without the program reading a price.[^escape-hatch] When you choose a trusted source, decide at the same time what the program does when it goes silent. +The third question, **what happens when publication stops**, needs an answer in the program's state machine. Price feeds go stale during outages, and committees deadlock. A program in which every way to withdraw needs a fresh fact strands everyone's funds when the source stops publishing. The staleness checks above stop the program acting on an old fact. For the other case, apply the *State Machines* chapter's rule that every participant must be able to get their assets out of every state: they must be able to do it without a new fact arriving. The lending market's suppliers can, and so can a borrower with no debt, as the admin-power section of the *Operating Financial Software* chapter shows. When you choose a trusted source, decide at the same time what the program does when it goes silent. **Fix the fact's meaning, and every term the payout depends on, when participants commit their tokens, not when the fact arrives.** The betting market fixes its outcome list when the admin opens it to bets, and its fee and betting close time at creation, before any stake lands; the close time must fall before the result is public, as the *Betting Market* chapter shows. The question bettors staked on cannot be rewritten before it is answered. -The price-manipulation section of the *Adversary* chapter covers the case these checks cannot catch: a signed fact that is fresh, confident, correctly verified, and engineered, a true report of a market the attacker just manipulated. +The price-manipulation section of the *Adversary* chapter covers the case these checks cannot catch: a signed price that is fresh, has a narrow confidence interval, is correctly verified, and reports a market the attacker just manipulated. ## What You Learnt @@ -48,5 +48,3 @@ The price-manipulation section of the *Adversary* chapter covers the case these [^negative-oil]: WTI crude oil futures traded as low as −$40.32 a barrel on 20 April 2020, the first negative price since the contract began trading in 1983, after storage filled and holders of the expiring May contract paid counterparties to take delivery off their hands. The EIA's account is at [eia.gov/todayinenergy/detail.php?id=43495](https://www.eia.gov/todayinenergy/detail.php?id=43495). [^pyth-guardians]: Until August 2026 Pyth Core updates were signed by Wormhole's nineteen guardians, and most published explanations of Pyth still describe that path. Pyth's governance retired that path in proposal OP-PIP-131, effective 26 August 2026: the updates posted to Solana are now signed by a multisig of five private keys of Pyth's own, and the onchain update fee was set to zero. The price data itself comes from Pyth Pro, whose Hermes service requires registration. The Pyth Receiver program and the `PriceUpdateV2` account kept their names and layout. See [forum.pyth.network/t/passed-op-pip-131-pyth-core-deprecation-svm/2671](https://forum.pyth.network/t/passed-op-pip-131-pyth-core-deprecation-svm/2671). - -[^escape-hatch]: The admin-power section of the *Operating Financial Software* chapter takes this up. diff --git a/financial-programming-concepts-in-solana/operating-financial-software.md b/financial-programming-concepts-in-solana/operating-financial-software.md index 6aab894c..782f971a 100644 --- a/financial-programming-concepts-in-solana/operating-financial-software.md +++ b/financial-programming-concepts-in-solana/operating-financial-software.md @@ -1,19 +1,19 @@ # Chapter 10: Operating Financial Software -Correct code is only the start. The rest of the job is testing it on every flow and running it safely once it holds other people's assets. +Shipping financial code takes tests of every flow, model checks of its arithmetic, a review before launch, and care in running it once it holds other people's assets. ## Test the Edges of Every Interaction Financial code fails at the edges the happy path never reaches: the refund, the second claim, the withdrawal that lands exactly on the health boundary. - **Build state through the program's own instruction handlers, never by injecting accounts.** A hand-built fixture skips the constraints under test, so a program can pass every test built on hand-made accounts and still be unusable, or unsafe, onchain. The one exception is an account a foreign program would have created, like a price feed. The managed fund's suite writes a Pyth price account as a fixture; the lending, prop AMM and perpetual futures suites post prices through a handler of their own or of a mock price-feed program. -- **Test the mirror image of every flow.** Withdraw as well as deposit, the ask as well as the bid, repay as well as borrow. The mirror is separate code, so a test of one copy says nothing about the other: a swap that works from USDC to NVDAx can fail from NVDAx to USDC. +- **Test the mirror image of every flow.** Withdraw as well as deposit, the ask as well as the bid, repay as well as borrow. The mirror is separate code, so a test of one direction says nothing about the other: a swap that works from USDC to NVDAx can fail from NVDAx to USDC. - **Use nonzero, asymmetric values.** A test with `duration: 0`, or the same amount on both sides, sits at the one value where `<=` and `>=` both hold, so it passes with the comparison written backwards. Equal amounts hide a second bug: a formula with its operands swapped computes the same total either way. Use unequal amounts you can recognize in a failed assertion's output, like 1,000 USDC against 3 NVDAx, so a wrong total tells you which input it was built from. - **Test both sides of every numerical boundary.** The withdrawal that lands exactly on the loan-to-value limit must succeed, and the same withdrawal plus one minor unit must fail. The lending suite's `withdraw_at_health_boundary_then_one_more_unit_fails` is that pair: a withdrawal that leaves borrowing power exactly equal to the debt passes, and one more minor unit is refused. -- **Test every attack by name.** Every check in a program chapter's failure-modes section gets a test that tries to get past it: the non-maker canceling an offer, the fee vault passed in place of the user vault, the second claim on a settled event. The handlers that lost Cashio and Wormhole, in the *Adversary* chapter, each accepted a substituted account. -- **Assert invariants, not just account values.** Conservation (a round trip creates no value), monotonicity (an index never decreases), directional rounding (no path rounds in the user's favor). An invariant test catches a whole bug class; an assertion on one account's value catches one instance of it. +- **Test every attack by name.** Every check in a program chapter's failure-modes section gets a test that tries to get past it: the non-maker canceling an offer, the fee vault passed in place of the quote vault, the second claim on a settled event. The Cashio and Wormhole handlers in the *Adversary* chapter each accepted a substituted account. +- **Assert invariants, not just account values.** Conservation (a round trip creates no value), monotonicity (an index never decreases), directional rounding (no path rounds in the user's favor). An invariant test fails for any input that breaks the invariant, including inputs nobody thought to check. -Every test in this list is quick to write and fast to run. The companion suites run on LiteSVM, which loads the compiled program into the test process, so a test runs in milliseconds. A rewritten Clock sysvar makes weeks of interest accrue inside one test, and `warp_to_slot()` ages a slot-stamped price. +Each of these tests runs in milliseconds: the companion suites run on LiteSVM, which loads the compiled program into the test process, so a test runs in milliseconds. A rewritten Clock sysvar makes weeks of interest accrue inside one test, and `warp_to_slot()` ages a slot-stamped price. ## Model Checking Goes Further Than Testing @@ -25,7 +25,7 @@ A test checks the values you thought of. A model checker checks all of them. The Though Kani calls its checks "proofs", a model check is not a formal proof. A formal proof is a mathematical argument a person writes in a proof assistant such as Lean, which the tool then checks step by step, and it can cover code a model checker cannot explore. The companion repository has model checks and tests, and no formal proofs. -The escrow program in the companion repository ships Kani harnesses for the parts of it a model checker can reach. Kani marks a harness with `#[kani::proof]`, which is why the directory is called `kani-proofs` and the harnesses are named `proof_*`; each one is a model check: +The escrow program in the companion repository ships Kani harnesses in `kani-proofs` for the parts of it a model checker can reach: - `proof_take_offer_conserves_value` checks that taking an offer conserves the total of each token, for every value rather than only the tested ones. - `proof_cancel_offer_returns_all_to_maker` checks that the refund path returns every vault token to the maker. @@ -37,7 +37,7 @@ A handler that moves tokens through the Token Program cannot be model-checked en A handler cannot ask whether the block it is running in will be finalized. There is no sysvar for it and no check to write, because a program always executes in a block no validator has voted on yet. If that block is skipped or loses a fork, the transaction did not happen, and every account it touched goes back with it. -The code that reads the chain has to answer that question, because it acts on what it finds. That code is the liquidator bot, the job that calls a crank (a maintenance handler anyone may call) on a schedule, the backend that marks an order paid, and the export your accountants close the month from. Some of what they do cannot be taken back. A reader of the chain asks for one of two states,[^confirmed] by the commitment level it passes with the request:[^commitment-levels] +The code that reads the chain has to answer that question, because it acts on what it finds. That code is the liquidator bot, the job that calls a crank (a maintenance handler anyone may call, which the *Lending and Borrowing* chapter builds) on a schedule, the backend that marks an order paid, and the export your accountants close the month from. Some of what they do cannot be taken back. A reader of the chain asks for one of two states,[^confirmed] by the commitment level it passes with the request:[^commitment-levels] | Commitment | What it means | What can still happen to it | | --- | --- | --- | @@ -53,14 +53,14 @@ 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. 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. +- **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 and has no handler that changes it, as the *Managed Fund* chapter shows. The lending program in the *Lending and Borrowing* chapter refuses a loan-to-value ratio (the share of collateral value a borrower may borrow) above the liquidation threshold (the debt level at which a position can be liquidated). So no loan it grants is liquidatable the moment it is taken. A parameter that must stay adjustable gets a bound on every update: the lending program caps borrow rates at 300% a year and refuses a liquidation bonus (the liquidator's discount on the collateral) 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. Let a parameter the admin can change apply only to deposits that arrive after the change, or bound it as the lending program bounds its borrow rates. +- **Give the admin no path to user deposits.** No instruction handler in the book's programs 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, 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 so plainly, that no handler moves user deposits to the operator, rather than claiming "non-custodial". - **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`). +- **Plan for the admin going silent.** Let nothing a user does wait on the operator: make a deposit, a withdrawal, or canceling their own offer run without an admin signature. 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`). - **Make upgrade authority trustworthy.** Ship fixes through a multisig with named holders rather than a single private key on one person's laptop, and say so where users can read it. A user who reads that knows a change to the code takes several named people to sign, and that one stolen laptop cannot ship one. -- **Keep each private key where losing it is survivable.** The private key that signs routine operations, like the order book's `withdraw_fees()`, lives in a hot wallet holding only what those operations need, and the treasury stays in cold storage. Back up every production private key, because a lost one takes its powers with it permanently, and generate new ones for mainnet rather than reusing the ones you tested with on devnet. +- **Keep each private key where losing it is survivable.** The private key that signs routine operations, like the order book's `withdraw_fees()`, lives in a hot wallet holding only what those operations need, and the treasury stays in cold storage. That handler succeeds as a no-op when the fee vault is empty, as the *Order Book Exchange* chapter says, so a scheduled job holding that private key can call it unattended. Back up every production private key, because a lost one takes its powers with it permanently, and generate new ones for mainnet rather than reusing the ones you tested with on devnet. ## Ship to Mainnet With a Second Opinion and a Verified Build diff --git a/financial-programming-concepts-in-solana/the-adversary.md b/financial-programming-concepts-in-solana/the-adversary.md index 1d1f5a5b..56ec8f31 100644 --- a/financial-programming-concepts-in-solana/the-adversary.md +++ b/financial-programming-concepts-in-solana/the-adversary.md @@ -9,7 +9,7 @@ Financial software on a public chain faces a harsher threat model than most soft Three consequences follow: - **Attacks are automated.** A flaw worth a cent per call is worth $10,000 to a script that calls it a million times. Judge every leak by what it pays when called as fast as the network allows. -- **Attackers see your transaction before it lands.** Anyone can try to get their transaction ordered before or after yours. A pending swap can be sandwiched: the attacker buys first to push the price against it, then sells after it. Any other profitable pending action can be copied and sent ahead of the original. The profit taken this way is called MEV, maximal extractable value. Ordering around a pending transaction is why every trade in this book carries a caller-side bound: a minimum output, or a maximum slippage (how far the price may move against them). The program cannot know the state it will run against; it can only refuse to run against a state worse than the user accepted. +- **Attackers see your transaction before it lands.** Anyone can try to get their transaction ordered before or after yours. A pending swap can be sandwiched: the attacker buys first to push the price against it, then sells after it. Any other profitable pending action can be copied and sent ahead of the original. The profit taken this way is called MEV, maximal extractable value. Ordering around a pending transaction is why most trades in this book carry a caller-side bound: a minimum output, or a maximum slippage (how far the price may move against them). The program cannot know the state it will run against; it can only refuse to run against a state worse than the user accepted. - **Attacks start at the extremes.** Zero amounts, empty pools, dust quantities, and exact thresholds are where comparisons flip and remainders round. Every attack below starts from one of them. ## Validation: Signers, Owners, and the Confused Deputy @@ -25,9 +25,9 @@ There are four checks: Anchor's account constraints perform all four. The program chapters use them on most accounts, and check the rest by hand in the handler body, as the managed fund does for the accounts it takes per asset. -Any account that holds one user's position, an order, a loan, a bet, needs both the signer check and the stored-address check. A signature proves who is asking, and an order owned by the program proves the order exists, but neither proves the order is the signer's. A program that checks only the signature and the owner lets any user cancel anyone's order. So a position records its owner when it is created, and every handler that acts on it requires that recorded owner to be the signer. Deriving the position's address from seeds that include the signer's public key does the same: a signer's public key leads only to their own accounts. The order book's `cancel_order()` uses both. It derives the trader's account from the signer's public key, and rejects with `Unauthorized` unless the order's recorded owner is the signer; `cancel_order_rejects_non_owner` tests it. +Any account that holds one user's position (an order, a loan, a bet) needs both the signer check and the stored-address check. A signature proves who is asking, and an order owned by the program proves the order exists, but neither proves the order is the signer's. A program that checks only the signature and the owner lets any user cancel anyone's order. So a position records its owner when it is created, and a handler that acts on the owner's instruction, like canceling the order, requires that recorded owner to be the signer. Deriving the position's address from seeds that include the signer's public key does the same: a signer's public key leads only to their own accounts. The order book's `cancel_order()` uses both. It derives the trader's account from the signer's public key, and rejects with `Unauthorized` unless the order's recorded owner is the signer; `cancel_order_rejects_non_owner` tests it. -A missing check lets the attacker **substitute** an account: pass a different account from the one the program expects. The classic form was named in 1988 as the **confused deputy**, a program with authority that is tricked into using it for someone else.[^confused-deputy] Your program is the deputy. It can sign for its vaults, and the attacker wants it to sign a transfer while reading the wrong accounts. The attacker passes their own account where the payment destination belongs. Or they pass a data account they created themselves, with the same layout as the real one and numbers of their choosing. Or they pass the fee vault where the user vault belongs: the two hold the same token under the same authority, and only a stored-address check tells them apart. That is why the order book checks every vault it is handed against the addresses its market recorded. +A missing check lets the attacker **substitute** an account: pass a different account from the one the program expects. The classic form was named in 1988 as the **confused deputy**, a program with authority that is tricked into using it for someone else.[^confused-deputy] Your program is the deputy. It can sign for its vaults, and the attacker wants it to sign a transfer while reading the wrong accounts. The attacker passes their own account where the payment destination belongs. Or they pass a data account they created themselves, with the same layout as the real one and numbers of their choosing. Or they pass the fee vault where the quote vault belongs. The two hold the same token under the same authority, so a signer or owner check cannot tell them apart, but a check against the address the market recorded can. That is why the order book checks every vault it is handed against the addresses its market recorded. > **War Story: Cashio, $48M, 2022.** Cashio's stablecoin was minted against deposited collateral. The mint instruction handler validated its accounts as a chain, trusting each account because the one before it named it. One link, the collateral token's mint, was never checked against anything the program already knew. An attacker created a fake account for every link, ending the chain in a worthless token, and minted two billion CASH against collateral of no value. They kept about $48M after refunding the accounts under $100,000.[^cashio] Every chain of accounts has to start from an account the program derived or recorded itself. @@ -53,12 +53,12 @@ The attack pays exactly what Bob deposited. A larger deposit loses less, but sti Bob can protect his own deposit with a minimum-output bound, like the `minimum_shares` argument the *Managed Fund* chapter's deposit handler takes, but that protects only a depositor whose wallet sets it. Four changes to the program stop the attack for every depositor: -- **Lock some shares that no one owns.** On the first deposit, the program adds a fixed number of shares to the supply without minting them to anyone, so the supply never starts at dust. Every formula counts the locked shares in the supply it divides by, so zeroing out Bob's deposit takes a donation of about a hundred times its size, nearly all of which belongs to shares nobody can withdraw. The *Constant-Product AMM* chapter runs the numbers for its `MINIMUM_LIQUIDITY` floor. +- **Lock some shares that no one owns.** On the first deposit, the program adds a fixed number of shares to the supply without minting them to anyone, so the supply never starts at dust. Every formula counts the locked shares in the supply it divides by, so zeroing out Bob's deposit takes a donation many times its size (about 101 times, with the AMM's floor of 100 units), nearly all of which belongs to shares nobody can withdraw. The *Constant-Product AMM* chapter runs the numbers for its `MINIMUM_LIQUIDITY` floor. - **Add virtual shares and virtual value to the formula.** The program adds a fixed number of shares to the supply and a fixed amount to the pool's value, but only inside the division, so it works normally even when no real shares exist. Say a million virtual shares and one virtual minor unit. The attacker's one-minor-unit deposit now buys a million shares, and the donation is split between their million and the virtual million. Bob's 1,000 USDC buys 1,999,999 shares, which redeem for 999.99975 USDC. The attacker's shares redeem for about 500 USDC, and the other half of the donation belongs to the virtual shares, which nobody can withdraw. -- **Ignore donations.** The program records the pool's size in its own state, as the lending market's `available_liquidity` and the managed fund's recorded holdings do, rather than reading the vault's token balance. Step 2 then changes the vault's balance and nothing the handler reads, so Bob's deposit mints what it should and the donation sits in the vault, lost to the attacker; the *Managed Fund* chapter runs Bob's numbers against its program. A recorded size stops donations only while the attacker has no other way to raise it. The *Perpetual Futures* chapter shows one: a provider who is also the pool's only trader can pay funding into it from their own position. The *Lending and Borrowing* chapter shows another: a lending reserve's size counts the interest its borrowers owe, and a supplier may borrow from their own reserve. One minor unit of interest is enough to start. After that, the fraction each of the attacker's own deposits loses to the floor stays in the pool and raises their share further. +- **Ignore donations.** The program computes the pool's size from figures it records in its own state, as the lending reserve's `total_liquidity()` and the managed fund's recorded holdings do, rather than reading the vault's token balance. Step 2 then changes the vault's balance and nothing the handler reads, so Bob's deposit mints what it should and the donation sits in the vault, lost to the attacker; the *Managed Fund* chapter runs Bob's numbers against its program. A recorded size stops donations only while the attacker has no other way to raise it. The *Perpetual Futures* chapter shows one: a provider who is also the pool's only trader can pay funding into it from their own position. The *Lending and Borrowing* chapter shows another: a lending reserve's size counts the interest its borrowers owe, and a supplier may borrow from their own reserve. One minor unit of interest is enough to start. After that, the fraction each of the attacker's own deposits loses to the floor stays in the pool and raises the price of their one share further. - **Require a large first deposit.** If the first deposit must be at least 100 USDC, the attacker's smallest position is 100,000,000 shares. Rounding Bob's 1,000 USDC down to nothing would then take a share price above 1,000 USDC, a vault holding a hundred billion dollars. -A program needs at least one of these four, chosen on purpose, and has to check that nothing else it allows raises the pool's value the way a donation does. The AMM locks shares, and the managed fund ignores donations. The lending market and the perpetual futures pool do both. Each ignores donations by pricing shares from figures it records rather than its vault's balance. Each also locks shares. +A program needs at least one of these four, chosen on purpose, and has to check that nothing else it allows raises the pool's value the way a donation does. The AMM locks shares, the managed fund ignores donations, and the lending market and the perpetual futures pool do both. ## A True Price Can Still Be a Manipulated Price @@ -77,15 +77,15 @@ Programs handle this by making manipulation expensive and slow: The *Arithmetic of Money* chapter set the rounding rules: what the user receives floors, once, and what the user owes, a debt or a fee, rounds up. This attack targets programs that do not follow them. Each rounding in the attacker's favor is worth less than a cent, so the attack repeats it thousands of times. It works best in an **empty or near-empty market**, where the attacker controls both numbers in the ratio and can choose the divisor that makes flooring give away the most. -> **War Story: the Hundred and Sonne drains, $7.4M and $20M.** Hundred Finance (2023) and Sonne Finance (2024) were both copies of the same lending design on EVM chains, and attackers drained both the same way. The attacker found or created a newly listed, empty market. They made a tiny deposit to mint a few shares, then donated a large sum to skew the exchange rate: the first two steps of the inflation attack. The inflated rate valued those few shares as collateral worth more than everything the other markets held, so the attacker borrowed all of it. Then they redeemed the donation: the floored share count burned one share short, so the donation came back while the debt stayed unpaid. An attacker drained Sonne a year after Hundred's post-mortem was published,[^hundred-sonne] because the code Sonne had copied still carried the flaw that post-mortem described. Copied code has to be kept up to date like any other. Where you can, use the original as a maintained dependency. Where you copy it, track the original project's fixes and security advisories and apply them to your copy. +> **War Story: the Hundred and Sonne drains, $7.4M and $20M.** Hundred Finance (2023) and Sonne Finance (2024) were both copies of the same lending design on EVM chains, and attackers drained both the same way. The attacker found or created a newly listed, empty market. They made a tiny deposit to mint a few shares, then donated a large sum to skew the exchange rate: the first two steps of the inflation attack. The inflated rate valued those few shares as collateral worth more than everything the other markets held, so the attacker borrowed all of it. Then they redeemed the donation: the floored share count burned one share short, so the donation came back while the debt stayed unpaid. An attacker drained Sonne a year after Hundred's post-mortem was published,[^hundred-sonne] because the code Sonne had copied still carried the flaw that post-mortem described. Where you can, use the original as a maintained dependency. Where you copy it, track the original project's fixes and security advisories and apply them to your copy. -Fix this by following those rounding rules everywhere, and by writing one kind of test: a **round-trip invariant test**. Deposit then immediately withdraw, or swap in then swap back, at hostile sizes (one minor unit, the whole pool, a prime number), and assert the caller never ends with more than they started with. The lending example's `deposit_redeem_round_trip_creates_no_value` runs the round trip fifty times at one size once interest has moved the exchange rate, and asserts the depositor never ends with more than they started with. +Fix this by following those rounding rules everywhere, and by writing one kind of test: a **round-trip invariant test**. Deposit then immediately withdraw, or swap in then swap back, at hostile sizes (one minor unit, the whole pool, a prime number), and assert the caller never ends with more than they started with. The lending example's `deposit_redeem_round_trip_creates_no_value` runs the round trip fifty times at one size once interest has moved the exchange rate, and makes that assertion after every trip. ## 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 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: +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 Extensions 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: - **Validate what a CPI returns and what it was given.** Calling a program does not make the accounts it touched trustworthy. The managed fund computes each swap's minimum output from the oracle, because the router it calls is outside its control. - **Read again what a CPI changed.** A framework that copies an account into memory when the handler starts keeps that copy through the CPI. After a transfer out of a vault, the copy still shows the old balance, and a check against it passes on a number that is no longer true. The AMM re-reads both vaults after its transfers, before it re-checks K.[^reload-per-framework] @@ -105,7 +105,7 @@ Where the role is the one reporting a result, like the betting market's admin se ## What You Learnt - Anyone can take the tokens a mistake on a public chain exposes: attacks are automated, anyone can get their transaction ordered around yours, and attacks start at the extremes of a range. -- Verify every supplied account, check that any position a handler acts on belongs to the signer, and start every chain of accounts from an account the program itself derived or recorded. +- Verify every supplied account, check that a handler acting for a position's owner has that owner's signature, and start every chain of accounts from an account the program itself derived or recorded. - Build a defense against the inflation attack into any pool that issues shares: locked shares, virtual shares, a pool size kept in program state, or a large minimum first deposit. - A true price can still be a manipulated price: time-weight it, act on the confidence interval, cap how much can move per window, and never lend more against a token than it would cost to move its market. - A rounding error in the user's favor gets repeated until the pool is empty, so write a round-trip invariant test to catch it. @@ -122,6 +122,6 @@ Where the role is the one reporting a result, like the betting market's admin se [^hundred-sonne]: Hundred Finance's own post-mortem is at [medium.com/hundred-finance/15-04-23-hundred-finance-hack-post-mortem-d895b618cf33](https://medium.com/hundred-finance/15-04-23-hundred-finance-hack-post-mortem-d895b618cf33), and Sonne's at [certik.com/skynet-report/sonne-finance-incident-analysis](https://www.certik.com/skynet-report/sonne-finance-incident-analysis). Hundred Finance is also written up at [halborn.com/blog/post/explained-the-hundred-finance-hack-april-2023](https://www.halborn.com/blog/post/explained-the-hundred-finance-hack-april-2023), and Sonne, under the name of the design both forked, at [blog.verichains.io/p/compound-v2-forked-vulnerability](https://blog.verichains.io/p/compound-v2-forked-vulnerability). -[^stored-address-per-framework]: Each framework implements the check in its own way, and the way changes between versions. In the companion repository, Anchor 2 puts `address = offer.maker` on the account being checked; Anchor 1 put `has_one = maker` on the account holding the record, and Quasar writes `has_one(maker)` there; the native version is an if-statement comparing the two addresses. The check is the same in all four. +[^stored-address-per-framework]: Each framework implements the check in its own way, and the way changes between versions. In the companion repository, Anchor 2 puts `address = offer.maker` on the account being checked; Anchor 1 puts `has_one = maker` on the account holding the record, and Quasar writes `has_one(maker)` there; the native version is an if-statement comparing the two addresses. The check is the same in all four. [^reload-per-framework]: In the companion repository, Anchor 1 keeps a copy of each account, so its AMM calls `reload()` on both vaults after the transfers. Anchor 2 and Quasar read token accounts in place, so there is nothing to reload. 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 c210b963..c47a4642 100644 --- a/financial-programming-concepts-in-solana/the-arithmetic-of-money.md +++ b/financial-programming-concepts-in-solana/the-arithmetic-of-money.md @@ -6,7 +6,7 @@ Every bug class in this chapter has shipped in a real program, and the worst of Every validator that executes your program must compute exactly the same result, or the network cannot agree on state, so a program's float operations are emulated in software rather than run on the hardware. That makes them slow. They also accumulate representation error (0.1 has no exact binary form) and round to nearest, a direction nobody chose. Token amounts are discrete anyway: every token comes in whole minor units, so representing them as integers is exact. -So keep token amounts, prices, rates, and everything derived from them as integers: u64 for amounts and u128 for intermediate math. No `f32`, no `f64`, no `powf`, no library that uses them internally. When a calculation needs fractions, scale integers up instead, as the Wads and Lots section below describes. When it needs a square root or an exponential, compute it with integer iteration: Newton's method for the square root, which the *Constant-Product AMM* chapter's `integer_sqrt()` implements, and exponentiation by squaring for a power, both covered later in this chapter. None of the programs in this book uses a float, and your programs should not either. +So keep token amounts, prices, rates, and everything derived from them as integers: u64 for amounts and u128 for intermediate math. No `f32`, no `f64`, no `powf`, no library that uses them internally. When a calculation needs fractions, scale integers up instead, as the Wads and Lots section below describes. When it needs a square root or a power, compute it with integer iteration: Newton's method for the square root, which the *Constant-Product AMM* chapter's `integer_sqrt()` implements, and exponentiation by squaring for a power, both covered later in this chapter. None of the programs in this book uses a float, and your programs should not either. ## Every Amount Is an Integer at a Declared Scale @@ -22,7 +22,7 @@ Two conventions build on minor units: - Fractional values (interest rates, indexes, exchange rates) use **fixed point**: scale everything by a large constant, so at a scale of 10^18, 1.05 is stored as 1_050_000_000_000_000_000. A value at that scale is called a **wad**,[^wad-origin] and it is a common scale on Solana. A wad is typically stored as a u128, because the largest u64, about 1.8 × 10^19, is only about 18 at a scale of 10^18. Multiplying two wads needs a wider intermediate still, such as a U192 or U256. Their raw product is at a scale of 10^36 until one 10^18 is divided back out, and it overflows a u128 once both values pass about 18. -- Order books often use **lots**, a market-level quantity unit; the *Order Book Exchange* chapter defines them. +- Order books often use lots, a market-level quantity unit; the *Order Book Exchange* chapter defines them. The wad is not a standard, though: Solana has no single fixed-point format. SPL's token-lending program used wads, held in a 192-bit `Decimal` type, but the spl-math library's `PreciseNumber` scales by 10^12. Kamino's lending program uses binary fixed point instead. Its numbers are u128s with the last 60 bits after the binary point, so 1.0 is stored as 2^60.[^fixed-point-formats] That gives about the same precision as a wad, and rescaling becomes a bit shift rather than a division. The companion repository mixes scales too: the lending program's `FIXED_POINT_SCALE` is 10^18, and the perpetual futures program's `FUNDING_PRECISION` is 10^9. @@ -30,34 +30,34 @@ So the rule for decimals applies to fixed point as well: **read the format of ev ## Every Operation Is Checked -Use checked arithmetic everywhere: `checked_add`, `checked_mul`, `checked_sub`, with a named overflow error. A plain `+` or `*` in a Rust release build **wraps**: when the result is too big for the type, it starts again from zero, so a `u64` balance one lamport past the maximum becomes a balance of almost nothing instead of an error. Overflow checks can be turned on in the build profile, but a financial program should not depend on a build setting. +Use checked arithmetic everywhere: `checked_add`, `checked_mul`, `checked_sub`, with a named overflow error. A plain `+` or `*` in a Rust release build **wraps**: when the result is too big for the type, it starts again from zero, so a `u64` balance one lamport past the maximum becomes a balance of zero instead of an error. Overflow checks can be turned on in the build profile, but a financial program should not depend on a build setting. -`saturating_*` clamps at the type's limit instead of failing, which is right only where a result past the limit has no meaning. In the programs in this book that limit is zero. A price feed's age floors at zero when the price feed's slot is momentarily ahead of the local clock. The interest accrued since the last refresh cannot be negative because the factor only grows. The order book's `remaining_quantity()` subtracts the filled quantity from the original with `saturating_sub`, and the lending market's `withdraw_obligation_collateral()` subtracts the withdrawn collateral's borrowing power from the obligation's allowance the same way: neither can be below zero. Where the accounting relies on the value, a clamp is worse than wrapping. `saturating_sub` turns 5 minus 10 into 0 rather than an error, so a debt that should have failed the calculation is recorded as no debt at all, and nothing anywhere reports a problem. The order book's fill accounting uses `checked_sub` for the filled quantity, and the comment beside it in `place_order.rs` says why. A silent clamp on the filled quantity would hide a remainder larger than the order, which is a bug the program must reject rather than silently accept. +`saturating_*` clamps at the type's limit instead of failing, which is right only where a result past the limit has no meaning. In the programs in this book that limit is zero. A price feed's age floors at zero when the price feed's slot is momentarily ahead of the local clock. The interest accrued since the last refresh cannot be negative because the factor only grows. The order book's `remaining_quantity()` subtracts the filled quantity from the original with `saturating_sub`, and the lending market's `withdraw_obligation_collateral()` subtracts the withdrawn collateral's borrowing power from the obligation's allowance the same way: neither can be below zero. Where the accounting relies on the value, a clamp is worse than wrapping, because wrapping produces an absurd number someone will notice. `saturating_sub` turns 5 minus 10 into a plausible 0 rather than an error, so a debt that should have failed the calculation is recorded as no debt at all, and nothing anywhere reports a problem. The order book's fill accounting uses `checked_sub` for the filled quantity, and the comment beside it in `place_order.rs` says why. A silent clamp on the filled quantity would hide a remainder larger than the order, which is a bug the program must reject rather than silently accept. ## Multiply Before Divide, with Wider Intermediates -Integer division throws away the remainder, so every division destroys information. The rule that follows: **do all multiplication before any division, and divide exactly once.** `amount * share / total` is right; `amount / total * share` is wrong (the first division usually yields zero). When a formula seems to need two divisions, refactor until it needs one. Dividing once is also rounding once, which is why the errors never accumulate: there is only ever one remainder to lose. +Integer division throws away the remainder, so every division destroys information. The rule that follows: **do all multiplication before any division, and divide exactly once.** `amount * share / total` is right; `amount / total * share` is wrong (the first division usually yields zero). When a formula seems to need two divisions, refactor until it needs one. Dividing once means one formula loses at most one remainder. -Multiplying first creates the second problem: intermediates overflow. Two u64 values multiply into up to 128 bits, so `price * quantity` can overflow u64 even when the final result, after division, fits easily. This is **phantom overflow**: rejecting or corrupting a perfectly legal trade because the middle of the calculation was too big, not the answer. Fix it by promoting to u128 (or a U256 type for chained multiplications) before multiplying, dividing, then narrowing back to u64 with an explicit checked conversion, so the only failure left is an amount too large to transfer. +Multiplying first creates the second problem: intermediates overflow. Two u64 values multiply into up to 128 bits, so `price * quantity` can overflow u64 even when the final result, after division, fits easily. This is **phantom overflow**: rejecting or corrupting a legal trade because the middle of the calculation was too big, not the answer. Fix it by promoting to u128 (or a U256 type for chained multiplications) before multiplying, dividing, then narrowing back to u64 with an explicit checked conversion, so the only failure left is an amount too large to transfer. -Some formulas need more than the four operations. The one every AMM needs is the first issuance of liquidity-provider (LP) tokens, the square root of a product of two u64s. It is computed with **Newton's method** on integers, a short loop that starts at the number itself and reaches the floor of the root within seventy passes for any product of two u64s. Each pass replaces the guess with the average of the guess and the target divided by the guess. The same holds for any real-valued function you need onchain: square roots, exponentials, and logs each become an integer iteration or approximation, and you pick one whose error you know the size and direction of. +Some formulas need more than the four operations. The one a constant-product automated market maker (AMM) needs is the first issuance of liquidity-provider (LP) tokens, the square root of a product of two u64s. It is computed with **Newton's method** on integers, a short loop that starts at the number itself and reaches the floor of the root within seventy passes for any product of two u64s. Each pass replaces the guess with the average of the guess and the target divided by the guess. The same holds for any real-valued function you need onchain: square roots, exponentials, and logs each become an integer iteration or approximation, and you pick one whose error you know the size and direction of. -## Every Division Rounds Against the User +## Round Every Division Against the User -Integer division nearly always leaves a remainder, and that remainder is **dust**: a fraction smaller than one minor unit. Every division rounds one way, **against the user, in favor of the program.** +Integer division nearly always leaves a remainder, and that remainder is **dust**: a fraction smaller than one minor unit. Round every division one way: **against the user, in favor of the program.** | What is being computed | Round | Examples | | --- | --- | --- | | What the program pays out | Down (floor) | Withdrawals, winnings, swap outputs, shares minted | | What the user owes | Up (ceil) | Debt, fees | -Keeping the dust in the pool is the only policy nobody can exploit: the pool belongs to its depositors, so the remainder stays with everyone rather than leaking to whoever transacts most often. One minor unit of rounding in the user's favor is invisible in a test, and an attacker who can loop a transaction thousands of times turns it into a drain; the rounding-error section of the *Adversary* chapter shows that attack succeeding in production. +Keeping the dust in the pool is the only policy nobody can exploit: the pool belongs to its depositors, so the remainder stays with everyone rather than leaking to whoever transacts most often. One minor unit of rounding in the user's favor is invisible in a test, and an attacker who can loop a transaction thousands of times turns it into a drain. The rounding-error section of the *Adversary* chapter shows a single floored share, against an inflated exchange rate, draining two lending markets. > **War Story: the SPL token-lending rounding bug.** In 2021, security researchers at Neodyme found that the widely-forked SPL token-lending program rounded its collateral-to-liquidity conversions with `try_round_u64()`, to the nearest integer, instead of flooring them. Rounding half-up means rounding in the user's favor half the time, and a carefully-sized deposit-withdraw loop could keep a lending pool's rounding on the favorable side, extracting value on every pass. Roughly $2.6B across Solana lending markets was exposed.[^spl-lending] The fix replaced `try_round_u64()` with `try_floor_u64()` at both conversion sites, so the pool rounds down whenever it pays out. ### Safety Checks Round Against the User Too -The table covers tokens changing hands. The same direction applies to the numbers a program checks before it lets tokens move at all. A lending market floors collateral value and ceils debt, so a position always looks slightly worse than it is, never slightly better. The program refuses a loan the borrower can only just afford, rather than granting one they cannot quite repay. +The table covers tokens changing hands. The same direction applies to the numbers a program checks before it lets tokens move at all. A lending market floors collateral value and ceils debt, so a position always looks slightly worse than it is, never slightly better. Rounding can refuse a borrow that exact arithmetic would put just inside the limit, but it never grants one that exact arithmetic would put outside it. ### Rounding Up Never Makes a Fee Exceed What It Is Charged On @@ -65,15 +65,15 @@ The order book follows its ceiling division with a check that the fee never exce ### Debt Rounds Up, So No Sub-Unit of It Is Ever Forgiven -Debt is ceiled wherever the program computes it, so no schedule of tiny repayments can reduce the debt by more than was repaid. Over-repayment clamps to the debt, so nobody is overcharged either. +The lending market ceils debt wherever it computes it, so no schedule of tiny repayments can reduce the debt by more than was repaid. Over-repayment clamps to the debt, so nobody is overcharged either. ## Invariants and Conservation: Assert After the Math An **invariant** is a condition that must hold before and after every state change, stated in code, checked at runtime. A check on the way in catches bad inputs; a check on the way out catches *your own bug*. The most useful invariants are conservation laws: - **Total in equals total out.** Every minor unit held in an escrow's vault, an order book's vaults, or a betting pool is accounted for on every path out of the program: paid out, refunded, or taken as fee, summing exactly. -- **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. +- **The pool product must not decrease.** The constant-product AMM's check that the product of its two reserves, K, never falls, which the *Constant-Product AMM* chapter builds. +- **The vaults cover 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 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. @@ -84,7 +84,7 @@ A degenerate value like zero can make an invariant hold vacuously: with one rese A lending market cannot visit every loan each second to add its interest, so it keeps one accumulation factor per reserve, a fixed-point number that grows by the current rate whenever anyone refreshes the reserve, and derives every live debt from it. The *Lending and Borrowing* chapter builds that factor. Its growth approximates compounding, with an error that depends on how often the market is touched: - **The everyday update approximates.** The factor grows by simple interest at each refresh: one multiply by (1 + rate × elapsed seconds), where true compounding over n seconds is (1 + rate)ⁿ, a power. Nothing bounds the interval between refreshes, though: `refresh_reserve()` charges whatever time has passed since the last one, and the undercharge grows with the gap. At 20% APR on a 10,000 USDC debt, one day untouched gives simple growth of 1.000547945 against compound growth of 1.000548095, about 0.0015 USDC undercharged, or 1,500 minor units, where a floored division loses at most one. Thirty days untouched undercharges about 1.36 USDC. The program leaves the refresh cadence to whoever runs the market. -- **When a formula needs the exact power, square.** For (1 + rate)ⁿ with a large n, do not multiply n times. Use repeated squaring instead: (1 + rate)⁸ is three squarings rather than seven multiplications, and in general about log₂(n) of them, each with checked math and floored the same way. This is **exponentiation by squaring**. It is the tool for a market that can sit untouched for a long stretch, where one catch-up update would otherwise treat months of interest as simple interest. +- **When a formula needs the exact power, square.** For (1 + rate)ⁿ with a large n, do not multiply n times. Use repeated squaring instead: (1 + rate)⁸ is three squarings rather than seven multiplications, and in general about log₂(n) of them, each squaring checked and rounded up, as the lending program rounds its factor update, so the result is never below the exact power. This is **exponentiation by squaring**. Use it for a market that can sit untouched for a long stretch, where one catch-up update would otherwise treat months of interest as simple interest. The rate itself is a policy choice; the arithmetic above only applies whatever rate the market sets, which for nearly every lending market is the kinked utilization curve the *Lending and Borrowing* chapter builds. @@ -92,7 +92,7 @@ The rate itself is a policy choice; the arithmetic above only applies whatever r - Keep onchain amounts as integers: no floats near funds, every amount at a declared scale read from the mint, and fractions in fixed point. Fixed-point formats differ between programs, so read the scale of any number another program produced before using it. - Multiply before divide, promote intermediates to u128, and check every operation with a named overflow error. -- Every division rounds against the user: floor what the program pays, ceil what the user owes, round once, and keep health checks conservative. +- Round every division against the user: floor what the program pays, ceil what the user owes, round once, and keep health checks conservative. - Assert invariants after the math, and know the degenerate values that let an invariant hold in a dangerous state. - Interest onchain compounds by approximation: simple interest at each refresh, or exponentiation by squaring where a single update must cover a long gap. diff --git a/prelude/about-the-author.md b/prelude/about-the-author.md index 7ddb0606..078be9ce 100644 --- a/prelude/about-the-author.md +++ b/prelude/about-the-author.md @@ -1,5 +1,5 @@ # About the Author -Mike MacCana creates Solana content at Quicknode, where he maintains the most comprehensive open library of tested Solana programs: the companion repository this book is built on. Before Quicknode, he ran developer education at the Solana Foundation, where he created a four-day education program that has been taught to Ivy League universities, and maintained developer training that was the highest-retention content on the Foundation's website. He co-created and presented the Solana Foundation Developer Bootcamp, watched over 330,000 times on YouTube. Before the Foundation, he won two prizes at the Solana Sandstorm hackathon for Portal Payments, which showed a recipient's identity before funds were sent and recovered wallets without a passphrase. He is a top-ten contributor on Solana Stack Exchange and among the top 1,000 of Stack Overflow's roughly 31 million users. He is the author of solana-helpers and Kite, two Solana libraries downloaded more than 30,000 times a week. +Mike MacCana creates Solana content at Quicknode, where he maintains the most comprehensive open library of tested Solana programs: the companion repository this book is built on. Before Quicknode, he ran developer education at the Solana Foundation, where he created a four-day education program that has been taught at Ivy League universities, and maintained developer training that was the highest-retention content on the Foundation's website. He co-created and presented the Solana Foundation Developer Bootcamp, watched over 330,000 times on YouTube. Before the Foundation, he won two prizes at the Solana Sandstorm hackathon for Portal Payments, which showed a recipient's identity before funds were sent and recovered wallets without a passphrase. He is a top-ten contributor on Solana Stack Exchange and among the top 1,000 of Stack Overflow's roughly 31 million users. He is the author of solana-helpers and Kite, two Solana libraries downloaded more than 30,000 times a week. Before Solana, Mike worked at large financial firms including Man Group, Credit Suisse, and CMC Invest, and at Google and Red Hat. He founded CertSimple, used by Buzzfeed, Monzo and most London fintechs, and created the python-docx library, which Microsoft uses in its own AI tools. He was the first product engineer at Humanloop, now part of Anthropic. He has spoken at Solana Breakpoint, Solana Accelerate, and Digicert's cryptography forum. He is a member of Superteam India. He lives in New York City and is on X as @mikemaccana. \ No newline at end of file diff --git a/prelude/introduction.md b/prelude/introduction.md index 9a9dea83..4a09ec46 100644 --- a/prelude/introduction.md +++ b/prelude/introduction.md @@ -9,9 +9,9 @@ This isn't a book about how Solana is built. It's about **building on it**. It i Financial products built on Solana have properties no traditional financial system offers: - **Settlement is atomic, instant, and global.** A trade and its settlement can be the same event, whether the two parties are in the same city or on different continents, and no failure leaves one side paid and the other empty-handed. There is no interval between one side paying and the other receiving, so nobody can step in during it. -- **Everyone reads the same accounts.** A traditional trade is recorded in two firms' separate books, and departments exist to check the copies against each other. Onchain there are no copies: a market's state is its accounts, and every party reads the same ones. There is nothing to reconcile. +- **Everyone reads the same accounts.** A traditional trade is recorded in two firms' separate books, and departments exist to check the copies against each other. Onchain there is one set of books: a market's state is its accounts, and every party reads the same ones. There is nothing to reconcile. - **Transactions cost a fraction of a cent**, regardless of the size of the transfer. Moving dollars the traditional way costs a percentage of the amount. Think back to the last time you bought a coffee: the café that sold it paid roughly 1.5% to 3.5% of the price in card fees, plus a fixed 10 to 30 cents.[^card-fees] Sending $200 across a border costs about $13 on average, and more than $40 on the worst corridors.[^remittance-scope] A flat fee makes it worth sending payments that were too small to bother with. -- **Markets are tradable 24 x 7.** No opening bell, no weekend close, no holiday calendar.[^after-hours] +- **Markets trade around the clock, every day.** No opening bell, no weekend close, no holiday calendar.[^after-hours] - **Assets are programmable.** The rules an institution enforces with staff and procedure (release the escrow when both sides have delivered, accrue the interest, liquidate the underwater loan, pay the winners their share) run as instruction handlers: code anyone can invoke and nobody has to remember. Every chapter of this book builds rules like these. - **Blockchains can move assets of any kind.** As of mid-2026, Solana is the most popular blockchain for trading real-world assets: equities, bonds, commodities, real estate, and exotics.[^rwa-scope] Solana is also one of the most active chains for trading crypto-native assets such as BTC.[^btc-scope] - **A deployed program reaches every user at once.** A traditional fund spends months working through intermediaries before it reaches investors: an exchange listing, market makers, brokerage approvals. Each gatekeeper adds cost and delay. A deployed program is reachable by every wallet on the network, and by every other program, the moment it exists. @@ -21,7 +21,7 @@ Open networks use these properties for permissionless markets anyone can join. T ## A Long-Lived Technical Book for Solana -Some technical books are out of date as soon as the release they document is. A smaller number stay useful for decades. *TCP/IP Illustrated* is still the book network engineers hand each other, thirty years on. *The UNIX and Linux System Administration Handbook* has outlasted every operating system release it ever mentioned. *Mastering Bitcoin* remains the standard text on its subject. What these books share is a decision to teach the primitive layer that does not change. Packets still flow the way *TCP/IP Illustrated* drew them. Processes still fork on Unix and Linux. The mental models survive even as the implementation evolves. This book provides the same long-term knowledge for building financial products on Solana. +Some technical books are out of date as soon as the release they document is. A smaller number stay useful for decades. *TCP/IP Illustrated* is still the book network engineers hand each other, thirty years on. *The UNIX and Linux System Administration Handbook* has outlasted every operating system release it ever mentioned. *Mastering Bitcoin* remains the standard text on its subject. What these books share is a decision to teach the primitive layer that does not change. Packets still flow the way *TCP/IP Illustrated* drew them. Processes still fork on Unix and Linux. This book provides the same long-term knowledge for building financial products on Solana. Solana changes quickly: network releases, framework versions, chain constants, and tools change often. A book that prints them is wrong within a year, and financial software is the worst possible place to learn from stale material. @@ -74,7 +74,7 @@ To keep the balances easy to track, every character starts every chapter with th The order in which this book introduces Solana concepts, and the visual language its figures use for accounts and their changes, were developed for the Solana Foundation's Professional Education Program. Most of the program chapters in Part II began as videos the author produced for Quicknode's YouTube channel, and their first drafts were transcripts of those videos, rewritten for the page. The manuscript was written by hand. -AI tools did the mechanical checking: spelling and grammar, terminology and naming across chapters, figures and the companion repository, and the diagrams' numbers against program state. +AI tools did the mechanical checking: spelling and grammar, terminology and naming across chapters, every name in the prose and figures against the companion repository, and the diagrams' numbers against program state. ## Corrections Are Welcome, and Expected @@ -90,7 +90,7 @@ Part I opens with the computer itself: one giant machine that spans the world, a [^most-active]: Artemis counted 4.1 million daily active addresses and 146 million transactions on Solana on August 31, 2026, against 1.7 million addresses on BNB Chain and under half a million on Ethereum. See [classic.artemis.ai/asset/solana](https://classic.artemis.ai/asset/solana). -[^card-fees]: The 1.5% to 3.5% is a United States café's all-in acceptance cost in 2026, which is the processor's margin on top of interchange. Visa's published US interchange alone, effective April 18, 2026, runs from 1.19% plus 10 cents for a debit card at a restaurant to about 2.7% for a credit card, and the Federal Reserve's Regulation II caps regulated debit at 0.05% plus 21 cents. Source: Visa, [usa.visa.com/dam/VCOM/download/merchants/visa-usa-interchange-reimbursement-fees.pdf](https://usa.visa.com/dam/VCOM/download/merchants/visa-usa-interchange-reimbursement-fees.pdf). +[^card-fees]: The 1.5% to 3.5% is a United States café's all-in acceptance cost in 2026: interchange plus the processor's margin. Visa's published US interchange alone, effective April 18, 2026, runs from 1.19% plus 10 cents for a debit card at a restaurant to about 2.7% for a credit card, and the Federal Reserve's Regulation II caps regulated debit at 0.05% plus 21 cents. Source: Visa, [usa.visa.com/dam/VCOM/download/merchants/visa-usa-interchange-reimbursement-fees.pdf](https://usa.visa.com/dam/VCOM/download/merchants/visa-usa-interchange-reimbursement-fees.pdf). [^remittance-scope]: In the third quarter of 2025 thirteen remittance corridors cost more than 20%, nine of them sending out of sub-Saharan Africa, against a global average of 6.36%. Source: The World Bank, Remittance Prices Worldwide issue 54, [remittanceprices.worldbank.org](https://remittanceprices.worldbank.org). diff --git a/scripts/build-pdf.mjs b/scripts/build-pdf.mjs index 3f91b099..c27e7832 100644 --- a/scripts/build-pdf.mjs +++ b/scripts/build-pdf.mjs @@ -1072,8 +1072,9 @@ const headingFigurePairs = await page.evaluate(() => { const printed = await page.pdf({ preferCSSPageSize: true }); await browser.close(); -// Chromium's print pipeline rounds the sheet up to the next 1/300in dot, so a -// 7.44 x 9.69in page comes out 0.24pt wider and taller than asked for, whatever +// Chromium's print pipeline adds one 1/300in dot to the sheet in each direction, +// even though the trim is already a whole number of dots, so a 7.44 x 9.69in +// page comes out 0.24pt wider and taller than asked for, whatever // units the size is written in and whether it comes from the CSS or from pdf()'s // own width and height. It anchors the content at the top left, so the slack // lands on the right and at the bottom, where it shows on the cover as a white diff --git a/solana-building-blocks/accounts-and-instruction-handlers.md b/solana-building-blocks/accounts-and-instruction-handlers.md index a49cdb44..f21c18e9 100644 --- a/solana-building-blocks/accounts-and-instruction-handlers.md +++ b/solana-building-blocks/accounts-and-instruction-handlers.md @@ -14,17 +14,17 @@ At Alice's address lives Alice's **account**: some data with an address. Her acc ![](figures/alice-on-the-curve.svg) -This figure is the first example of how this book shows people, programs and data on Solana. **A dot sitting on the curve is an address with a private key behind it**, so someone can sign for it. The figures never label those dots, because the dot's place on the curve already says it. Later you will see addresses drawn *off* the curve, listed instead with the **seeds** (the inputs) they were derived from; those have no private key. +This figure is the first example of how this book shows people, programs and data on Solana. **A dot sitting on the curve is an address with a private key behind it**, so someone can sign for it. After this first figure, the figures leave those dots unlabeled, because the dot's place on the curve already says it. Later you will see addresses drawn *off* the curve, listed instead with the **seeds** (the inputs) they were derived from; those have no private key. > **Key pairs are random, and that is safe.** Nobody issues Alice her key pair: her device generates it from random bytes, with no registry to consult and no permission to ask. That only works because collisions are, in practice, impossible. A Solana private key is drawn from a space of 2^256 possibilities, a number about a thousandth of the count of atoms in the observable universe. If every person on earth generated a billion key pairs per second, the expected wait before any two ever matched would still be about a hundred times the age of the universe. When this book says an address "belongs" to Alice, it means Alice has generated a key pair that nobody else will ever generate, and that only Alice has the private key. A **wallet** is a key pair being used to sign transactions, with the private key stored somewhere safe. Without its apps and branding, a saved wallet is small: a key pair file on disk, like the `id.json` file the Solana CLI creates, holding 64 bytes. Whoever has those bytes *is* that wallet. -> **64 bytes, but only 32 are secret.** An `id.json` key pair file holds the 32-byte private key followed by the 32-byte public key. The second half is a convenience, not a necessity: the public key is mathematically derived from the private key, so software can recompute it from the first 32 bytes at any time. The private key is the only secret, and the only part that needs protecting. +> **64 bytes, but only 32 are secret.** An `id.json` key pair file holds the 32-byte private key followed by the 32-byte public key. The second half is a convenience, not a necessity: the public key is mathematically derived from the private key, so software can recompute it from the first 32 bytes at any time. ## There Is More Than One Solana Computer -The bold curve in the figure is labeled **Solana Devnet**, and the faint curves behind it are mainnet and localnet, because there is more than one Solana computer. Each running instance is a **cluster**: the same software, a separate universe. What appears at an address in one cluster can be (and usually is) completely different to what appears at the same address in a different cluster. +The bold curve in the figure is labeled **Solana Devnet**, and the faint curves behind it are mainnet and localnet, because there is more than one Solana computer. Each running instance is a **cluster**: the same software, a separate universe. What appears at an address in one cluster can be (and usually is) completely different from what appears at the same address in a different cluster. | Cluster | Purpose | Assets have value? | Where it runs | | ------------ | ------------------------------------------------------------------- | ------------------- | ---------------------------- | @@ -34,15 +34,15 @@ The bold curve in the figure is labeled **Solana Devnet**, and the faint curves There is also **testnet**, where future versions of the Solana software itself are tested before a release reaches mainnet. Other clusters are started up now and then to try out a particular proposed network feature. Your own work happens almost entirely on localnet and devnet: localnet for the fast build-and-test loop, then devnet for a rehearsal on the same software real users will meet. Mainnet is where a finished program goes live. Assets there have real value, which is why a program should be thoroughly audited before it is deployed there. -Each cluster is reached at a URL. Programs and wallets never talk to the network directly: they send transactions to an **RPC endpoint**, a server that holds the cluster's state and relays transactions into it. The endpoint URL is the setting that decides which cluster the Solana SDK reads from, writes to, and deploys to. +Each cluster is reached at a URL. Apps and wallets never talk to the network directly: they send transactions to an **RPC endpoint**, a server that holds the cluster's state and relays transactions into it. The endpoint URL is the setting that decides which cluster the Solana SDK reads from, writes to, and deploys to. The Solana Foundation runs a public endpoint for each cluster, rate limited and meant for learning and light development; anything with real users runs against a dedicated endpoint from an RPC provider instead. Quicknode, this book's publisher, is one of several. Alice's SOL is devnet SOL. Think of it as the paper banknotes from a board game, which feel real inside the game and buy nothing outside it. Devnet SOL and devnet USDC are given away free.[^faucets] Alice's address is the same on every cluster; the *account* at her address exists per cluster, and nothing in this book needs mainnet tokens to work through. -## Receiving Doesn't Need A Signature, Spending Does +## Receiving Doesn't Need a Signature, Spending Does -Meet Bob. Bob has his own key pair, and his public key is another point on the same curve. At Bob's address lives Bob's account, holding a small balance of SOL from the same faucet. To send something to Bob, Alice needs only his address. To spend whatever arrives there, Bob needs the private key that matches his address. +Meet Bob. Bob has his own key pair, and his public key is another point on the same curve. At Bob's address lives Bob's account, holding a small balance of devnet SOL, given away free like Alice's. To send something to Bob, Alice needs only his address. To spend whatever arrives there, Bob needs the private key that matches his address. ![](figures/alice-and-bob-on-the-curve.svg) @@ -75,21 +75,21 @@ The transfer instruction does not actually say 0.05 SOL. It says 50,000,000 **la ## A Transaction's Instructions Succeed or Fail Together -Alice's transaction executes **atomically**: every instruction succeeds, or the whole transaction never happened. If Alice's balance cannot cover the 50,000,000 lamports she wants to send Bob, the transfer fails, and the memo is never written either, even though the Memo Program itself had no complaint. If both instructions succeed, the state changes: Bob's account balance rises by 50,000,000 lamports, and the log says thanks. **There is no partial state**, no "the payment cleared but the delivery failed." One change survives a failure: the transaction fee. The network charges it for processing the transaction, so a transaction that fails still lowers the fee payer's lamport balance. The base fee is 5,000 lamports per signature at the time of writing, which is why Alice ends this transaction at 0.949995 SOL rather than 0.95. +Alice's transaction executes **atomically**: every instruction succeeds, or none of their changes take effect. If Alice's balance cannot cover the 50,000,000 lamports she wants to send Bob, the transfer fails, and the memo is never written either, even though the Memo Program itself had no complaint. If both instructions succeed, the state changes: Bob's account balance rises by 50,000,000 lamports, and the log says thanks. **There is no partial state**, no "the payment cleared but the delivery failed." One change survives a failure: the transaction fee. The network charges it for processing the transaction, so a transaction that fails still lowers the fee payer's lamport balance. The base fee is 5,000 lamports per signature at the time of writing, which is why Alice ends this transaction at 0.949995 SOL rather than 0.95. For financial software, atomicity is a primitive you design with. Inside a transaction there is no settlement risk between steps: - The escrow program in the *Escrow* chapter moves Alice's payment and Bob's stock in one transaction, so if either transfer fails neither happens. -- 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. +- The lending market in the *Lending and Borrowing* chapter runs `refresh_reserve()` and `refresh_obligation()` before a borrow, so the borrow reads interest and prices brought up to date in the same transaction. +- 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. **Collateral** is assets pledged to secure a loan, which the lender may seize if it goes unpaid. A flash loan needs none 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 (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. +Some arbitrage strategies are possible only because transactions are all-or-nothing. So is a liquidation (repaying part of a borrower's loan once it passes a safety limit, in return for some of their collateral at a bonus) funded by a flash loan instead of the liquidator's own tokens. ![](figures/atomic-transaction.svg) ## Less Work Costs Users Less -Work is metered in **compute units** per transaction, and a transaction that sets a priority fee pays it per unit it asks for, so a handler that does less work costs its users less. That is the reason the order book in the *Order Book Exchange* chapter credits each fill to a balance instead of making one transfer per fill: a trader collects every fill's tokens in a single transfer. The programs in this book consolidate work wherever they can for the same reason. +Work is metered in **compute units** per transaction. A transaction may add a **priority fee**, an extra fee per compute unit that gets it processed sooner, and pays it on every unit it requests, so a handler that does less work costs its users less. That is the reason the order book in the *Order Book Exchange* chapter credits each fill to a balance instead of making one transfer per fill: a trader collects every fill's tokens in a single transfer. The programs in this book consolidate work wherever they can for the same reason. ## Every Transaction Is Signed @@ -101,7 +101,7 @@ The signature is also a grant of authority, but programs decide what it authoriz - **Moving her lamports.** The System Program owns the account at Alice's address, and its `transfer()` handler moves lamports out of an account only when the transaction carries that address's signature. - **Paying the transaction fee**, the fraction of a cent the transaction costs, which comes out of her balance. -- **Any change a program has recorded her address as allowed to make**, such as moving tokens out of a token account whose owner is her address, or closing that account. +- **Any change a program has recorded her address as allowed to make**, such as moving tokens out of a token account that names her as its owner (a second meaning of "owner", which the *Tokens* chapter covers), or closing that account. An action's **authority** is the address whose signature that action requires; Solana uses the word for every such address. A program records that address in an account, and the handler checks that the transaction carries its signature before doing the work: @@ -112,7 +112,7 @@ An action's **authority** is the address whose signature that action requires; S Different authorities in the same program can be given to different addresses. -> **What if funds need more than one person's approval?** A transaction can carry several signatures, but for standing control of funds there is a **multisig**: an account that records a set of owners and how many of them must agree, and acts only once enough have. Any authority can be a multisig address rather than one person's wallet. +> **What if funds need more than one person's approval?** A transaction can carry several signatures, but for standing control of funds there is a **multisig**: a program that records a set of owners and how many of them must approve, and signs only once enough have. Any authority can be a multisig address rather than one person's wallet. ## Accounts Store Data, Not Just SOL @@ -122,7 +122,7 @@ Besides its address, every account records three things a program reads: a lampo ![](figures/account-dissected.svg) -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. +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. 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. @@ -142,9 +142,9 @@ Every account sits at a 32-byte **address**, usually shown base58-encoded. Addre A week later it is Bob's turn to pay. He owes Alice twenty-five dollars, and dollars on Solana typically mean a **stablecoin**, a token that tracks the dollar one for one, like **USDC**, backed by treasury bills or other assets tied to the currency. -SOL is the network's **native token**, the asset it understands without any program's help. Every other token is defined by a program instead, and is formally an **SPL token**, after the Solana Program Library the original token program shipped in. Most people call all of them tokens, and so does this book. Each distinct token is identified by its **mint**, the account that is the asset's identity onchain, and the *Tokens* chapter covers mints. +SOL is the network's **native token**: its balance is a field of every account, not a token program's record. Every other token is defined by a program instead, and is formally an **SPL token**, after the Solana Program Library the original token program shipped in. Most people call all of them tokens, and so does this book. Each distinct token is identified by its **mint**, the account that is the asset's identity onchain, and the *Tokens* chapter covers mints. -Bob's SOL lives in his own account, but his USDC does not: each token balance lives in a **token account** of its own; a wallet's is at an address anyone can compute from the holder's address, the token program, and the mint. Bob's wallet finds Alice's USDC account from her address alone; the *Tokens* chapter covers token accounts. +Bob's SOL lives in his own account, but his USDC does not: each token balance lives in a **token account** of its own, which the *Tokens* chapter covers. So Bob signs a transaction carrying one instruction: the **Token Program**'s `transfer_checked()` handler, moving 25 USDC from his USDC account to Alice's. The instruction says 25,000,000, because USDC's minor unit is a millionth, and like a lamport amount it is an integer of minor units. Bob's signature is what authorizes the debit, as Alice's authorized hers: the Token Program checks that the account being debited belongs to Bob, and that Bob signed. Alice's USDC balance rises from 1,000 to 1,025 with no signature from her. diff --git a/solana-building-blocks/figures/atomic-transaction.svg b/solana-building-blocks/figures/atomic-transaction.svg index 1d53dd19..ddedf8ac 100644 --- a/solana-building-blocks/figures/atomic-transaction.svg +++ b/solana-building-blocks/figures/atomic-transaction.svg @@ -14,7 +14,7 @@ 3. another exchange program swap() 5 NVDAx for 1,010 USDC - 4. a flash-loan lender + 4. the flash-loan lender repay() 1,000 USDC + fee a flash loan: four instructions, three programs diff --git a/solana-building-blocks/figures/pda-derivation.svg b/solana-building-blocks/figures/pda-derivation.svg index 186832de..98988a6e 100644 --- a/solana-building-blocks/figures/pda-derivation.svg +++ b/solana-building-blocks/figures/pda-derivation.svg @@ -49,7 +49,7 @@ the offer's vault 250 → 0 USDC - authority: Alice's offer + authority: Alice's offer PDA BOB'S ADDRESS + diff --git a/solana-building-blocks/figures/programs-on-the-curve.svg b/solana-building-blocks/figures/programs-on-the-curve.svg index 246f3e43..1eba7c70 100644 --- a/solana-building-blocks/figures/programs-on-the-curve.svg +++ b/solana-building-blocks/figures/programs-on-the-curve.svg @@ -36,5 +36,5 @@ on the curve: a public key, with a private key behind it - off the curve: a built-in address, and no private key exists + off the curve: a built-in address, with no known private key diff --git a/solana-building-blocks/figures/token-accounts-and-atas.svg b/solana-building-blocks/figures/token-accounts-and-atas.svg index 0e2e36db..7f7f71eb 100644 --- a/solana-building-blocks/figures/token-accounts-and-atas.svg +++ b/solana-building-blocks/figures/token-accounts-and-atas.svg @@ -2,7 +2,7 @@ Not every token account is an ATA token accounts - each one holds a mint, an owner, and an amount + each one holds a mint, an authority, and an amount associated token accounts one per owner and mint, at a derivable address diff --git a/solana-building-blocks/figures/transfer-using-transfer-fee-extension.svg b/solana-building-blocks/figures/transfer-using-transfer-fee-extension.svg index 79ce4019..81ff51b1 100644 --- a/solana-building-blocks/figures/transfer-using-transfer-fee-extension.svg +++ b/solana-building-blocks/figures/transfer-using-transfer-fee-extension.svg @@ -36,6 +36,6 @@ the mint's transfer fee withholds 1 ACME for the issuer 100 ACME was sent and 99 ACME arrived - not drawn: the ACME mint, whose transfer-fee extension withholds the 1 ACME; the accounts name it only as a seed + not drawn: the ACME mint, named as a seed and as the transfer_checked() mint argument diff --git a/solana-building-blocks/pdas.md b/solana-building-blocks/pdas.md index ffdd2bb2..5f73a6d2 100644 --- a/solana-building-blocks/pdas.md +++ b/solana-building-blocks/pdas.md @@ -2,15 +2,15 @@ You have been using PDAs since the *Tokens* chapter: every associated token account in this book lives at a PDA of the Associated Token Program, derived from the three seeds that chapter listed. -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. +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: it adds one more seed byte, the **bump**, and tries its values from 255 downward 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; the boxout under *Funds Under a PDA's Authority* says what "sign" means here. -**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: +**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 records this way, except the order book's 180 KB book account, which is too large to create from inside a program and sits at a public key the client generated: - 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. - The order book stores each market at an address made from `"market"` + the two mints. - The lending market stores each borrower's loan file at an address made from `"obligation"` + the market + the borrower's address. -- The perpetual futures venue stores each position at an address made from `"position"` + the pool + the owner + the side. +- The perpetual futures venue stores each position at an address made from `"position"` + the pool + the trader's address + the side. ![](figures/pda-key-value-store.svg) @@ -18,7 +18,7 @@ A **program derived address**, or PDA, is an address computed from a program's I ## Seeds Make Data Findable -The market for a token pair, a user's position in it, and the offer with a given ID all live at addresses anyone can compute from the seeds, so exactly one thing exists per key. A program finds any of them by recomputing its address from the seeds. +The market for a token pair, a user's position in it, and the offer with a given ID all live at addresses anyone can compute from the seeds. One set of seeds gives one address, so one account exists per key. A program finds any of them by recomputing its address from the seeds. ## Seeds Make Relationships Verifiable diff --git a/solana-building-blocks/time.md b/solana-building-blocks/time.md index 9309867f..341e509c 100644 --- a/solana-building-blocks/time.md +++ b/solana-building-blocks/time.md @@ -34,7 +34,7 @@ The slot count is how many slots have gone by. Programs read it from the same Cl Two things affect how much wall-clock time a number of slots adds up to, and anyone converting between the two should know both: -- **Each slot usually runs slightly longer than the network intends.** The network sets a target, 200 milliseconds, and real slots mostly run a little over it.[^slot-time-live] A slot runs long when its leader was slow, or when the network waited out a timeout for a block that never came. A slot is a turn, not a tick of a clock: the next one starts when the last one ends, so every delay carries forward and nothing pulls the count back to real time. That extra time adds up but does not compound, because in normal running slots do not get slower as they go, and each adds only its own delay. Three slots at 219 milliseconds take 657 milliseconds, 57 more than the 600 they were sized for. So a slot-counted window covers more wall-clock time than it was sized for, which loosens it rather than tightening it. +- **Each slot usually runs slightly longer than the network intends.** The network sets a target, 200 milliseconds, and real slots mostly run a little over it.[^slot-time-live] A slot runs long when its leader was slow, or when the network waited out a timeout for a block that never came. A slot has no fixed length: the next one starts when the last one ends, so every delay carries forward and nothing pulls the count back to real time. Three slots at 219 milliseconds take 657 milliseconds, 57 more than the 600 they were sized for. So a slot-counted window covers more wall-clock time than it was sized for, which loosens it rather than tightening it. - **The count stops when the chain does.** No block is produced during a halt, so the count stands still while real time moves on. The section on halts below covers what that does. Slots are grouped into **epochs** of 432,000 slots, which is exactly a day at 200 milliseconds a slot, and about two hours longer if slots actually average 219 milliseconds. @@ -57,10 +57,10 @@ Every program in this book that times something chose by that rule; the table at Three programs in this book accrue value over time, and all count it in seconds: - **The lending market accrues interest on debt.** It carries one number per reserve, a borrow accumulation factor, and advances it by the seconds elapsed since `last_accrual_timestamp`; the *Lending and Borrowing* chapter builds the factor. -- **The perpetual futures venue accrues funding on open positions.** It does the same, storing `cumulative_funding` alongside `last_funding_timestamp`, at a rate per second fixed when the pool is created. +- **The perpetual futures venue accrues funding on open positions**, a periodic payment charged to whichever side, long or short, has the larger total position size. It does the same, storing `cumulative_funding` alongside `last_funding_timestamp`, at a rate per second fixed when the pool is created. - **The managed fund accrues its annual management fee.** It counts the seconds since `last_fee_accrual_timestamp` in the same way, and its `collect_fees()` refuses a timestamp at or before the stored one with `NoTimeElapsed`. -All three advance by elapsed time, and interest and funding decide what somebody owes, which decides whether a position can be liquidated. A leader can move the timestamp by less than a second in its turn, and at a 20% annual rate even two seconds of interest is about one hundred-millionth of the debt. A position that close to the liquidation line crosses it anyway if the leader waits two seconds, so moving the clock gains the leader nothing. +All three advance by elapsed time, and interest and funding decide what somebody owes, which decides whether a position can be liquidated. A leader can move the timestamp by less than a second in its turn, and at a 20% annual rate even two seconds of interest is about one hundred-millionth of the debt. A position within a hundred-millionth of its liquidation line crosses it two seconds later anyway, so moving the clock gains a leader nothing. Counting slots instead would have no advantage here, and would be inaccurate: a rate quoted per year becomes a rate per slot only through the slot length, which the network changes and does not deliver exactly. If slots run about 10% long, a year holds that many fewer of them, so a market that assumes 200 milliseconds charges a borrower quoted 20% a year about 18.2%. That is nearly a tenth of all the interest, against the hundred-millionth a leader can move by shifting the timestamp. @@ -75,12 +75,12 @@ The network's target slot length is 200 milliseconds.[^slot-simd] A program can get that length three ways: - **From Anza's documentation, written into your program.**[^anza-docs] This is correct until the network next shortens the slot, and wrong from then on, which the section on a shortened slot covers. -- **From the feature gates.** Changes to the network ship as **features**, built into the validator software, switched off, and turned on across the network at the start of an epoch. Each shortening of the slot ships as one, so a program can check which of them are live and take the shortest, falling back to 400 milliseconds when none are.[^check-slot-time] A new slot time applies from the start of the epoch after its feature gate activates, not from the moment it does, so check the epoch as well as the feature gate. +- **From the feature gates.** Changes to the network ship as **features**, built into the validator software, switched off, and turned on across the network at the start of an epoch. Each shortening of the slot ships as one, so a program can check which of them are live and take the shortest, falling back to 400 milliseconds when none are.[^check-slot-time] A slot-time feature that activates at the start of one epoch takes effect at the start of the next, so check the current epoch as well as the feature gate. - **From the network itself, in future.** While no account reports the slot time in milliseconds at the time of writing, this is expected to change.[^onchain-params] Check Anza's documentation before writing either technique above, in case such an account now exists. Avoid the Solana SDK's `DEFAULT_MS_PER_SLOT`: it is fixed when the SDK is compiled, so what was true on build day may not reflect the network a program is running on. -To measure the average the network is actually delivering, take the seconds since `epoch_start_timestamp` in the Clock sysvar and divide by the slots since this epoch began, which `EpochSchedule` supplies. Check the divisor before that division: it is zero on an epoch's first slot. That measurement comes from the leader-written timestamp, so it belongs in offchain tooling, not in a handler. A handler that needs wall-clock time should read the timestamp itself, as the lending market's interest does. Multiplying a slot count by a measured slot length carries the timestamp's error and adds the slot count's. +To measure the average the network is actually delivering, take the seconds since `epoch_start_timestamp` in the Clock sysvar and divide by the slots since this epoch began, which `EpochSchedule` supplies. Check the divisor before that division: it is zero on an epoch's first slot. That measurement comes from the leader-written timestamp, so it belongs in offchain tooling, not in a handler. A handler that needs wall-clock time should read the timestamp itself, as the lending market's interest does. A slot count multiplied by a measured slot length has both errors: the timestamp's and the count's. ## Who Fills First Is a Counter, Not a Clock @@ -99,7 +99,7 @@ A **price feed** is an account that some publisher keeps updated with a price an | Program | Refuses a price older than | Measured in | | --- | --- | --- | | the lending market | 25 slots | slots | -| the prop AMM | 150 slots | slots | +| the proprietary automated market maker (prop AMM) | 150 slots | slots | | the perpetual futures venue | 150 slots | slots | | the managed fund | 60 seconds | seconds | @@ -137,10 +137,10 @@ A halt is rare: at the time of writing, block production has not stopped for yea No block is produced during a halt, so the slot count does not move. Wall-clock time does. The first block after a restart carries a slot roughly where the last one left off. Its timestamp cannot jump the hours the halt took either, because of the 400-millisecond-per-slot bound above. At the time of writing, the timestamp trails real time after a restart and gains on it at about one second per second, so a nine-hour halt leaves it trailing for about nine hours more.[^clock-catch-up] That gap has three consequences: - **Accrual by timestamp charges the halt, late.** The lending market's interest, the perpetual futures venue's funding, and the managed fund's fee all count seconds, so a nine-hour halt accrues nine hours. It arrives over the nine hours of catch-up rather than in the first block. The loans were outstanding and the suppliers' deposits were locked for those nine hours, so charging for them is accurate. -- **Neither kind of freshness window refuses the old price on its own.** The lending market's 25-slot window sees a price stamped one slot ago and would accept a number nine hours old. The managed fund's sixty-second window does no better, because the timestamp it reads is trailing, so a price published just before the halt looks seconds old. The damage is largest on the perpetual futures venue, where leverage multiplies a bad price across every open position. +- **Neither kind of freshness window refuses the old price on its own.** The lending market's 25-slot window sees a price stamped one slot ago and would accept a number nine hours old. The managed fund's sixty-second window does no better, because the timestamp it reads is trailing, so a price published just before the halt looks seconds old. Accepting one would do the most harm on the perpetual futures venue, where leverage (holding a position larger than the collateral behind it) multiplies a bad price across every open position. - **A deadline passes at twice real speed after a restart.** The fundraiser's day count stands still during the halt, then runs at twice real speed until the timestamp catches up, so a contributor with an hour left before the halt gets about half an hour after it. The fundraiser survives it because `refund()` has no closing deadline, so contributors can still get their money back after the halt. A design that allowed refunds only during a narrow window would have left them unable to. -Every freshness window needs one more check. The `LastRestartSlot` sysvar[^last-restart-slot] carries the slot of the most recent cluster restart, or zero if there has never been one, and a price from before that restart is stale however few slots have passed since. The lending market, the prop AMM, the perpetual futures venue, and the managed fund all make this check in their oracle readers: any price stamped at or before the restart slot is rejected with `PricePredatesRestart`, and the market pauses valuation until the publisher posts again. Make it in any program that checks a price's age, in slots or in seconds. +Every freshness window needs one more check. The `LastRestartSlot` sysvar[^last-restart-slot] carries the slot of the most recent cluster restart, or zero if there has never been one, and a price from before that restart is stale however few slots have passed since. The lending market, the prop AMM, the perpetual futures venue, and the managed fund all make this check when they read a price feed. Any price stamped at or before the restart slot is rejected with `PricePredatesRestart`, and each program refuses that price feed until the publisher posts again. Make it in any program that checks a price's age, in slots or in seconds. The order book comes through a halt untouched, because it acts on neither clock: the timestamp it records is never compared, and queue positions resume exactly as they were. @@ -161,8 +161,8 @@ A slot count that spans one of these changes cannot be converted at today's figu | Program | What it times | Its answer | Why | | --- | --- | --- | --- | | fundraiser | its funding deadline | whole days from one stored timestamp | a week of slots is about 16 hours out | -| lending market | interest accrual, and price freshness | timestamps for interest, slots for freshness | seconds against a year are nothing, and a stale price can be exploited | -| perpetual futures | funding accrual, and price freshness | timestamps for funding, slots for freshness | the same, and leverage multiplies a stale price | +| lending market | interest accrual, price freshness, and a refresh in the same slot | timestamps for interest, slots for freshness and the refresh | seconds against a year are nothing, and a stale price can be exploited | +| perpetual futures | funding accrual, its price average, a profit warm-up, and price freshness | timestamps for funding and the average, slots for the 10-slot warm-up and for freshness | the same, and leverage multiplies a stale price; the warm-up is a span of a couple of seconds the program stamps itself | | prop AMM | price freshness | slots | a stale price can be exploited | | managed fund | an annual fee, and price freshness | timestamps for both | seconds against a year are nothing, and the price feed stamps seconds | | options | its expiry | the timestamp | a date the two parties agreed, days away | @@ -174,8 +174,8 @@ A slot count that spans one of these changes cannot be converted at today's figu - A program has three clocks: the timestamp (a leader's choice within a bound), the slot count (nobody's choice, runs long, stops in a halt), and a counter of its own (order only). - Count slots for any span shorter than about 20 seconds that your own program stamps, where nobody can move the count and its drift stays under a second or two. Use the wall clock for any longer span, including a rate or a deadline quoted to a person, with a margin at any deadline. Use a counter of your own for order of arrival. - Accrual by timestamp charges per second whatever the slot length: advance a stored factor by the seconds since the last stored timestamp. Counting slots instead undercharges when slots run long, in proportion to how long they run. -- Give every price a freshness window, set by each consumer, in the unit the source uses: slots where the program chooses, seconds where the price feed published seconds. -- A slot count converts to wall-clock time only at the slot length in force, and only across a span where that length held. Read that length from the slot-time feature gates or measure it against the epoch. Anza's documentation carries the current figure. +- Give every price a freshness window, set by each consumer, in the unit the price feed stamps: slots where it stamps the slot, seconds where it publishes seconds. +- A slot count converts to wall-clock time only at the slot length in force, and only across a span where that length held. A program reads that length from the slot-time feature gates; offchain tooling can measure the average against the epoch. Anza's documentation carries the current figure. - A shorter slot tightens every slot-counted window, and a window compiled in as a constant takes a redeploy to retune; a rate counted in seconds is untouched, while one counted in slots accrues faster without saying so. - A deadline counted in whole days floors, so compute both sides of it from the same expression. The window closes exactly that many 24-hour periods after it opened, at the same time of day, so say so when you write the terms down. - After a halt the slot count resumes where it stopped and the timestamp trails real time, so accrual charges the halt late, and deadlines and freshness windows read the gap wrong until it catches up. diff --git a/solana-building-blocks/tokens.md b/solana-building-blocks/tokens.md index 684d8ffc..3e99628f 100644 --- a/solana-building-blocks/tokens.md +++ b/solana-building-blocks/tokens.md @@ -2,7 +2,7 @@ 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 +- stablecoins like USDC - equities like NVDAx, SPCXx, and TSLAx[^tokenized-equity-volume] - the share tokens the book's own programs mint @@ -20,11 +20,11 @@ Two tokens are the same asset if they come from the same mint address, and diffe ## A Mint Is a Factory, and the Mint Authority Is Its Boss -A mint does not just describe a type of token; it manufactures it. New tokens come into existence only when the mint's mint authority signs an instruction to the Token Program's `mint_to()` handler. The supply counter rises and the new tokens land in a token account. Destroying tokens needs no mint authority: any holder can burn their own tokens with `burn()`, signing as the owner of the token account that holds them, and the supply falls. No other handler changes the supply. +A mint does not just describe a type of token; it manufactures it. New tokens come into existence only when the mint's mint authority signs an instruction to the Token Program's `mint_to()` (or `mint_to_checked()`) handler. The supply counter rises and the new tokens land in a token account. Destroying tokens needs no mint authority: any holder can burn their own tokens with `burn()` (or `burn_checked()`), signing as the owner of the token account that holds them, and the supply falls. 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 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. +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. Every mint this book's programs create has a PDA as its mint authority, so new shares are minted only by those programs' handlers, never by a person. Those mints are each lending reserve's share mint, the fund's share mint, and the liquidity providers' share mints in the *Constant-Product AMM* and *Perpetual Futures* chapters. 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. @@ -40,7 +40,7 @@ A token account holds one owner's balance of a particular type of token, and its > **A token account's "owner" is not its owner program.** Every account has an owner program, and for a token account that is always the token program: the only program that can change its bytes. The **owner** recorded in a token account's data is something else: the address that can move its tokens. Alice is the owner of her USDC account, and the Token Program moves USDC out of it only when Alice signs (or a **delegate** she has approved, up to the amount she approved). Solana's own tools use "owner" for both. In this book, when an address "owns" a token account or "holds" its tokens, it means the second: that address is the token account's owner, and its signature is what moves the tokens. The figures label this field `authority`, because a program's vault is owned by a PDA and the book calls that PDA the vault's authority. -The **associated token account** (ATA) is the canonical token account for a given wallet and mint, at an address anyone can derive from three seeds: the owner's address, the token program that manages the mint, and the mint. Put in Alice's address, the token program, and the USDC mint, and out comes the address of Alice's USDC account; put in Bob's, and out comes his. This is how a wallet finds Alice's USDC account from nothing but her address, and how a program can check that a token account an instruction supplies really is hers. +The **associated token account** (ATA) is the canonical token account for a given wallet and mint, at a PDA of the **Associated Token Program**, derived from three seeds: the owner's address, the token program that manages the mint, and the mint. Put in Alice's address, the token program, and the USDC mint, and out comes the address of Alice's USDC account; put in Bob's, and out comes his. This is how a wallet finds Alice's USDC account from her address, and how a program can check that a token account an instruction supplies really is hers. An ATA lives at a PDA, so it sits off the curve with no private key, and no signature for the address itself can ever exist. That does not stop Alice spending her USDC: she is the token account's owner, and her signature is what moves it. The *PDAs* chapter explains the mechanism. @@ -52,7 +52,7 @@ Not every token account is an ATA. **A token account can live at any address**, ## Decimals Define the Minor Unit -The mint's **decimals** set the minor unit, the raw integers that programs use to calculate and transfer assets: with USDC's 6 decimals, $10 moves as 10,000,000. Read decimals from the mint and never assume them, and transfer with `transfer_checked()`, which fails unless the caller states the decimals the mint really has. Every program in the companion repository does both. +The mint's **decimals** set the minor unit, the raw integers that programs use to calculate and transfer assets: with USDC's 6 decimals, $10 moves as 10,000,000. Read decimals from the mint and never assume them, and transfer with `transfer_checked()`, which fails unless the caller states the decimals the mint really has. Every program in this book does both. ## The Freeze Authority Can Freeze Any Holder's Account @@ -60,7 +60,7 @@ The mint's other authority is the freeze authority, which can freeze and thaw in ## Two Token Programs: Classic and Token Extensions -Solana has two token programs. The **Classic Token Program** is the original, and most tokens in circulation use it. The **Token Extensions Program** is its successor: the same core instructions and accounts, plus opt-in, per-mint features. Well-written programs support both through a common interface (like Anchor's `token_interface`), and only require that every token account in one instruction uses the same token program. Every program in this book works that way. The Token Program named so far is the Classic one; this book's programs accept either. +Solana has two token programs. The **Classic Token Program** is the original, and most tokens in circulation use it. The **Token Extensions Program** is its successor: the same core instructions and accounts, plus opt-in, per-mint features. Well-written programs support both through a common interface (like Anchor's `token_interface`), and require that every token account in one instruction uses the same token program. Every program in this book works that way. The Token Program named so far is the Classic one. | Extension | What it does | What it means for your program | | --- | --- | --- | @@ -75,7 +75,7 @@ Solana has two token programs. The **Classic Token Program** is the original, an | **scaled UI amount** | A multiplier stored in the mint, which the issuer updates for stock splits and dividends | The displayed balance changes while the raw amount stays fixed: calculate in raw amounts | | **metadata** | The name, symbol, and a link to more details, stored in the mint | Display only: the mint address still identifies the asset | -Stablecoin and tokenized-security issuers use most of these together: the Solana Foundation's Mosaic templates for both turn on default account state, pausable, permanent delegate, confidential transfers, and metadata, and the tokenized-security template adds permissioned burn and scaled UI amount.[^mosaic] Before building on a token you did not issue, check which extensions it uses: Solana Explorer lists them on the mint's page,[^solana-explorer] and the table's last column says what each one means for your program. +Stablecoin and tokenized-security issuers use most of these together: the Solana Foundation's Mosaic templates[^mosaic] for both turn on default account state, pausable, permanent delegate, confidential transfers, and metadata, and the tokenized-security template adds permissioned burn (burning needs a designated authority's approval) and scaled UI amount. Before building on a token you did not issue, check which extensions it uses: Solana Explorer lists them on the mint's page,[^solana-explorer] and the table's last column says what each one means for your program. ![](figures/transfer-using-transfer-fee-extension.svg)