diff --git a/building-financial-products-in-solana/betting-market.md b/building-financial-products-in-solana/betting-market.md index 042e5ed2..90346fa2 100644 --- a/building-financial-products-in-solana/betting-market.md +++ b/building-financial-products-in-solana/betting-market.md @@ -3,7 +3,7 @@ What is going to be the top-grossing film this month? You have an opinion, and so does everyone else. There are two main ways to let everyone put money on it: - **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 banned bookmakers there and left authorized parimutuel pools as the only legal way to bet on horses. Prediction markets such as Polymarket and Kalshi use a similar system: there is no bookmaker, and bettors bet against each other. +- **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 had banned bookmakers the year before its law of 2 June 1891 gave authorized racing societies the right to run parimutuel pools, which left the pool as the only legal way to bet on horses there.[^parimutuel-dates] Prediction markets such as Polymarket and Kalshi use a similar system: there is no bookmaker, and bettors bet against each other. This chapter builds a parimutuel market. The program lives at `finance/betting-market` in the companion repository. Structurally it is the *Escrow* chapter with many parties and one new, serious question: who decides what happened in the real world? @@ -17,7 +17,7 @@ In a parimutuel market, all stakes on all outcomes go into one vault, and when t ## The Operator Creates the Market -Maria is the admin. Her public key is recorded in the program's config, and only she can create events, add outcomes, open betting, settle, and cancel; her revenue is a 2% fee, charged only on the losing pool. She runs `initialize_config()` once, naming herself admin, USDC as the token, herself as fee recipient, and 200 **basis points** (2%) as the fee. For this month's market she runs `initialize_event()` with a description ("top-grossing film this month") and a `betting_closes_at` of midnight on the last day of the month. A close time already in the past fails with `CloseTimeInPast`. The event starts as a Draft, and Maria calls `add_outcome()` twice: "Glowbugs 3" and "The Quiet Floor". Then she calls `open_betting()`, which moves the event to Open. The first signer of `initialize_config()` becomes the admin. Every handler after it checks the signer against that address and rejects anyone else with `Unauthorized`; `test_only_admin_can_initialize_event` and the last step of `test_open_betting_needs_two_outcomes` check it. +Maria is the admin. Her public key is recorded in the program's config, and only she can create events, add outcomes, open betting, settle, cancel, and close a finished event's accounts; her revenue is a 2% fee, charged only on the losing pool. She runs `initialize_config()` once, naming herself admin, USDC as the token, herself as fee recipient, and 200 **basis points** (2%) as the fee. For this month's market she runs `initialize_event()` with a description ("top-grossing film this month") and a `betting_closes_at` of midnight on the last day of the month. A close time already in the past fails with `CloseTimeInPast`. The event starts as a Draft, and Maria calls `add_outcome()` twice: "Glowbugs 3" and "The Quiet Floor". Then she calls `open_betting()`, which moves the event to Open. The first signer of `initialize_config()` becomes the admin. Every handler after it checks the signer against that address and rejects anyone else with `Unauthorized`; `test_only_admin_can_initialize_event` and the last step of `test_open_betting_needs_two_outcomes` check it. ![](figures/betting-maria-setup.svg) @@ -73,7 +73,7 @@ For Alice: 300 + floor(300 × 196 ÷ 400) = 300 + 147 = **447 USDC**. For Bob: 1 None of these payouts depended on when anyone bet. Had Alice bet first, when Glowbugs 3 held only her stake, or last, after Bob, she would still receive 447 USDC, because the payout reads the pools at the close. A racetrack's tote board works the same way: the odds it shows keep moving until the race starts, and every winning ticket is paid at the final odds. -The multiply runs in u128 and divides exactly 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, and no claimant receives it. +The multiply runs in u128 and divides exactly 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, until the event closes and `close_event()` pays it to the fee recipient. 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 (checked by `test_double_claim_fails`), and there is no `claimed` flag to forget to check. @@ -83,7 +83,11 @@ The claim closes the bet account, refunding its rent to the bettor. **A closed a 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 both attempts fail. -An Open event can also be voided: if the studio pulls both films from release, there is no result to settle. `cancel_event()`, admin-only, flips the status to Cancelled, and every bettor reclaims their exact stake through `claim_refund()`, no fee taken, using the same close-the-account-to-prevent-double-claims mechanic (checked by `test_cancel_and_refund`). `cancel_event()` works on a Draft too, which is how Maria abandons a market she set up wrong before anyone has staked on it. Settled and Cancelled are the two ways an Open event ends, and in both every bet account has a handler that pays it out or closes it. +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, because each account's address derives from its parent's: a bet's from its outcome's, an outcome's from the event's. She calls `close_outcome()` once per outcome. It refuses while the event is still a Draft or Open (`EventNotFinished`) and while any bet is open (`BetsStillOpen`), because a bet's claim or refund needs its outcome account to check against; otherwise it closes the outcome account and returns its rent to her. 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 be found again, with its old `total_amount`, by a later event created with the same event ID. 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. 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 (a top-up reuses the account) 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()` sends it to Maria along with the fee. `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. + +![](figures/betting-maria-close.svg) + +An Open event can also be voided: if the studio pulls both films from release, there is no result to settle. `cancel_event()`, admin-only, flips the status to Cancelled, and every bettor reclaims their exact stake through `claim_refund()`, no fee taken, using the same close-the-account-to-prevent-double-claims mechanic (checked by `test_cancel_and_refund`, which then closes the canceled event's accounts, with nothing left in the vault to pay). `cancel_event()` works on a Draft too, which is how Maria abandons a market she set up wrong before anyone has staked on it. Settled and Cancelled are the two ways an Open event ends, and in both every bet account has a handler that pays it out or closes it. Every account the event created names its closer, as the *State Machines* chapter asks: a bet closes through `claim_winnings()`, `close_losing_bet()`, or `claim_refund()`; an outcome through `close_outcome()`; the vault and the event through `close_event()`. The config is Maria's state for the whole deployment, and no handler closes it. ![](figures/betting-state-machine.svg) @@ -104,6 +108,7 @@ Every checked operation maps to `MathOverflow`. There are no rates and no curves - 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. +- `open_bets` counts the event's bet accounts and `open_outcomes` its outcome accounts; no outcome closes while a bet is open, and the event does not close while an outcome is. - The outcome list never changes once the event is Open, and no stake enters the pool at or after `betting_closes_at`. - Only the admin's signature can settle or cancel, and only the payout formula decides amounts. @@ -112,24 +117,28 @@ Every checked operation maps to `MathOverflow`. There are no rates and no curves - **The resolution itself.** Maria could settle the wrong outcome, maliciously or by mistake, and the decision is hers alone. This program is deliberately the simplest case; the *Offchain Truth* chapter covers what it costs a trusted party to lie, 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`. - **Double claims** fail because the bet account is gone, as the claim step showed. +- **Closing an account a claim still needs** fails too. `close_outcome()` and `close_event()` refuse while the event is a Draft or Open (`EventNotFinished`) or any bet is open (`BetsStillOpen`), and `close_event()` refuses while any outcome is (`OutcomesStillOpen`). So no claim is left with nothing to claim against, and no stale outcome waits for a later event with the same ID (checked by `test_close_event_refused_while_a_bet_is_open` and `test_close_event_refused_while_event_is_open`). Only Maria's signature closes anything (`test_only_admin_can_close_outcomes_and_event`). - **Changing the terms after betting opens** is impossible: the outcome list, the fee, and the close time are all fixed before the first bet can land. - **Betting on a known result**: a bettor who learns the result before Maria settles cannot stake on it, because `place_bet()` stops at `betting_closes_at` with `BettingClosed`. The program cannot tell when the result becomes public, so choosing a close time before that moment is Maria's job. - **Betting after settlement or cancellation** rejects with `EventNotOpen`, whether the event was settled or canceled. ## Testing Considerations -The suite builds all state through the program's own handlers, and its centerpiece is `test_full_lifecycle`, config through claims. It uses the same pools as the story above (400 on the winner, 200 on the loser, a 4 USDC fee) and asserts balances after the bets, the settlement, and the claims. Around it, one test per failure mode: the admin-only handlers, the outcome list locking when betting opens, the two-outcome minimum, both edges of the close time, a close time in the past, bet-after-settle, double claims, empty-outcome settlement, and cancel-and-refund. `test_close_losing_bet_only_after_settle_and_only_for_losers` checks that `close_losing_bet()` works only on a settled event and only for a bet that lost. When you write your own market, copy the shape: the happy path checks the math, and every adversarial code path gets a test whose name says what it refuses to allow. +The suite builds all state through the program's own handlers, and its centerpiece is `test_full_lifecycle`, config through claims. It uses the same pools as the story above (400 on the winner, 200 on the loser, a 4 USDC fee) and asserts balances after the bets, the settlement, and the claims. Around it, one test per failure mode: the admin-only handlers, the outcome list locking when betting opens, the two-outcome minimum, both edges of the close time, a close time in the past, bet-after-settle, double claims, empty-outcome settlement, and cancel-and-refund. The last of those also closes the canceled event once its refunds are in. `test_close_losing_bet_only_after_settle_and_only_for_losers` checks that `close_losing_bet()` works only on a settled event and only for a bet that lost. `test_close_event_pays_dust_to_fee_recipient_and_returns_rent` closes a settled event whose payouts left dust, and `test_close_event_refused_while_a_bet_is_open`, `test_close_event_refused_while_event_is_open`, and `test_only_admin_can_close_outcomes_and_event` check the three ways a close is refused. When you write your own market, copy the shape: the happy path checks the math, and every adversarial code path gets a test whose name says what it refuses to allow. ## What You Learnt from Building the Betting Market A racetrack's parimutuel pool needs a tote board, a licensed operator, and a payout window. Here a vault and four expressions of arithmetic do those jobs: the pool is the odds, the program carries no risk, and the house takes a fee only from the losers. -This chapter added five concepts: +This chapter added six 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's job is making that trust visible and narrow: their signature picks the winner and does 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. +- **Every account names its closer.** A bet closes when it pays out or is refunded, and the admin closes the outcomes and the event once no bet is open, children before parents, so no rent stays locked and no stale account can be found by a later event. 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. + +[^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/escrow.md b/building-financial-products-in-solana/escrow.md index f0bcd440..88e79ae5 100644 --- a/building-financial-products-in-solana/escrow.md +++ b/building-financial-products-in-solana/escrow.md @@ -20,7 +20,7 @@ Alice starts, like every character in this book, with one SOL and 1,000 USDC; sh The naive version is that Alice sends Bob her USDC and waits. But what if Bob takes the USDC and leaves? An **escrow** lets Alice and Bob swap without having to trust each other. -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, as anyone who has sent money overseas knows, the third party takes a percentage of the amount for itself: even a plain transfer through an intermediary costs 6.5% on average worldwide, and more than 20% on the worst corridors.[^remittance-cost] +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, as anyone who has sent money overseas knows, the third party takes a percentage of the amount for itself: even a plain transfer through an intermediary costs about 6.4% on average worldwide, and more than 20% on the worst corridors.[^remittance-cost] ![](figures/escrow-traditional-escrow.svg) 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 2d3bd388..a55b5eaf 100644 --- a/building-financial-products-in-solana/figures/betting-alice-claim.svg +++ b/building-financial-products-in-solana/figures/betting-alice-claim.svg @@ -112,7 +112,7 @@ - + betting program @@ -126,56 +126,58 @@ close_losing_bet() cancel_event() claim_refund() + close_outcome() + close_event() - - "CONFIG" - - - config + + "CONFIG" + + + config - - "EVENT" + EVENT ID - - - event - - status: Settled - winning_outcome_index: 0 - winning_pool: 400 - distributable_losing_pool: 196 + + "EVENT" + EVENT ID + + + event + + status: Settled + winning_outcome_index: 0 + winning_pool: 400 + distributable_losing_pool: 196 - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 0: Glowbugs 3 - - total_amount: 400 + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Glowbugs 3 + + total_amount: 400 - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 1: The Quiet Floor + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: The Quiet Floor - - EVENT'S ADDRESS + - TOKEN PROGRAM + USDC MINT - - - vault: the event's USDC - - 149 USDC - authority: the event PDA - - + + EVENT'S ADDRESS + + TOKEN PROGRAM + USDC MINT + + + vault: the event's USDC + + 149 USDC + authority: the event PDA + + 1. claim_winnings() · 447 USDC signed by the event PDA diff --git a/building-financial-products-in-solana/figures/betting-maria-close.svg b/building-financial-products-in-solana/figures/betting-maria-close.svg new file mode 100644 index 00000000..faf4b007 --- /dev/null +++ b/building-financial-products-in-solana/figures/betting-maria-close.svg @@ -0,0 +1,169 @@ + + Maria closes the event's accounts + + + Solana Devnet + + + + + + + Alice + + + + ALICE'S ADDRESS + + TOKEN PROGRAM + + USDC MINT + + + Alice's USDC + + + + + + + Bob + + + + BOB'S ADDRESS + + TOKEN PROGRAM + + USDC MINT + + + Bob's USDC + + + + + + + Carol + + + + CAROL'S ADDRESS + + TOKEN PROGRAM + + USDC MINT + + + Carol's USDC + + + + + + Maria (admin) + + 1 SOL + + + + MARIA'S ADDRESS + + TOKEN PROGRAM + + USDC MINT + + + Maria's USDC + + + + + + betting program + + initialize_config() + initialize_event() + add_outcome() + open_betting() + place_bet() + settle_event() + claim_winnings() + close_losing_bet() + cancel_event() + claim_refund() + close_outcome() ×2 + close_event() + + + + "CONFIG" + + + config + + + + "EVENT" + EVENT ID + + + event + + status: Settled + open_bets: 0 + open_outcomes: 0 + + + + + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Glowbugs 3 + + total_amount: 400 + + + + + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: The Quiet Floor + + total_amount: 200 + + + + + + EVENT'S ADDRESS + + TOKEN PROGRAM + USDC MINT + + + vault: the event's USDC + + 0 USDC + authority: the event PDA + + + + 1. close_outcome() ×2: both outcome accounts closed + 2. close_event(): vault and event account closed + 3. each rent returns to Maria, who paid it + no USDC moves: 147 and 49 divided exactly, so no dust was left + where flooring leaves dust, close_event() pays it to the fee recipient + outcomes close first: a bet's address derives from its outcome's, an outcome's from the event's + + + curve continued + + + + + + + + + + USDC mint + + + diff --git a/building-financial-products-in-solana/figures/betting-maria-setup.svg b/building-financial-products-in-solana/figures/betting-maria-setup.svg index 51fca82a..56f0235f 100644 --- a/building-financial-products-in-solana/figures/betting-maria-setup.svg +++ b/building-financial-products-in-solana/figures/betting-maria-setup.svg @@ -71,7 +71,7 @@ - + betting program @@ -85,66 +85,68 @@ close_losing_bet() cancel_event() claim_refund() + close_outcome() + close_event() - - "CONFIG" - - - config - - admin: Maria - token_mint: USDC - fee_recipient: Maria - default_fee_bps: 200 (2%) - NEW + + "CONFIG" + + + config + + admin: Maria + token_mint: USDC + fee_recipient: Maria + default_fee_bps: 200 (2%) + NEW - - "EVENT" + EVENT ID - - - event - - status: Open - total_pool: 0 - fee_bps: 200 - betting_closes_at: month end - NEW + + "EVENT" + EVENT ID + + + event + + status: Open + total_pool: 0 + fee_bps: 200 + betting_closes_at: month end + NEW - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 0: Glowbugs 3 - - total_amount: 0 - NEW + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Glowbugs 3 + + total_amount: 0 + NEW - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 1: The Quiet Floor - - total_amount: 0 - NEW + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: The Quiet Floor + + total_amount: 0 + NEW - - EVENT'S ADDRESS + - TOKEN PROGRAM + USDC MINT - - - vault: the event's USDC - - 0 USDC - authority: the event PDA - NEW + + EVENT'S ADDRESS + + TOKEN PROGRAM + USDC MINT + + + vault: the event's USDC + + 0 USDC + authority: the event PDA + NEW every later call checks Maria's signature against config.admin; anyone else gets Unauthorized 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 e1857750..397fc7cc 100644 --- a/building-financial-products-in-solana/figures/betting-place-bets.svg +++ b/building-financial-products-in-solana/figures/betting-place-bets.svg @@ -125,7 +125,7 @@ - + betting program @@ -139,59 +139,61 @@ close_losing_bet() cancel_event() claim_refund() + close_outcome() + close_event() - - "CONFIG" - - - config + + "CONFIG" + + + config - - "EVENT" + EVENT ID - - - event - - status: Open - total_pool: 600 - fee_bps: 200 + + "EVENT" + EVENT ID + + + event + + status: Open + total_pool: 600 + fee_bps: 200 - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 0: Glowbugs 3 - - total_amount: 400 + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Glowbugs 3 + + total_amount: 400 - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 1: The Quiet Floor - - total_amount: 200 + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: The Quiet Floor + + total_amount: 200 - - EVENT'S ADDRESS + - TOKEN PROGRAM + USDC MINT - - - vault: the event's USDC - - 600 USDC - authority: the event PDA - - - - + + EVENT'S ADDRESS + + TOKEN PROGRAM + USDC MINT + + + vault: the event's USDC + + 600 USDC + authority: the event PDA + + + + 300 USDC 100 USDC 200 USDC diff --git a/building-financial-products-in-solana/figures/betting-settlement.svg b/building-financial-products-in-solana/figures/betting-settlement.svg index a2a8f5f9..276a1a3f 100644 --- a/building-financial-products-in-solana/figures/betting-settlement.svg +++ b/building-financial-products-in-solana/figures/betting-settlement.svg @@ -107,7 +107,7 @@ - + betting program @@ -121,61 +121,63 @@ close_losing_bet() cancel_event() claim_refund() + close_outcome() + close_event() - - "CONFIG" - - - config - - admin: Maria - token_mint: USDC - fee_recipient: Maria - default_fee_bps: 200 (2%) + + "CONFIG" + + + config + + admin: Maria + token_mint: USDC + fee_recipient: Maria + default_fee_bps: 200 (2%) - - "EVENT" + EVENT ID - - - event - - status: Settled - winning_outcome_index: 0 - winning_pool: 400 - distributable_losing_pool: 196 + + "EVENT" + EVENT ID + + + event + + status: Settled + winning_outcome_index: 0 + winning_pool: 400 + distributable_losing_pool: 196 - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 0: Glowbugs 3 - - total_amount: 400 + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Glowbugs 3 + + total_amount: 400 - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 1: The Quiet Floor + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: The Quiet Floor - - EVENT'S ADDRESS + - TOKEN PROGRAM + USDC MINT - - - vault: the event's USDC - - 596 USDC - authority: the event PDA - - + + EVENT'S ADDRESS + + TOKEN PROGRAM + USDC MINT + + + vault: the event's USDC + + 596 USDC + authority: the event PDA + + 4 USDC (the fee) signed by the event PDA Maria signs settle_event(): Glowbugs 3 wins diff --git a/building-financial-products-in-solana/figures/order-book-alice-ask.svg b/building-financial-products-in-solana/figures/order-book-alice-ask.svg index c1a41315..2952775e 100644 --- a/building-financial-products-in-solana/figures/order-book-alice-ask.svg +++ b/building-financial-products-in-solana/figures/order-book-alice-ask.svg @@ -162,7 +162,7 @@ - + order book program @@ -172,24 +172,26 @@ cancel_order() settle_funds() withdraw_fees() + pause_market() + resume_market() - - "MARKET" + NVDAX MINT + - USDC MINT ADDRESS - - - market - - authority: Maria - base_mint: NVDAx - quote_mint: USDC - fee_basis_points: 25 - tick_size: 1 - min_order_size: 1 - base_lot_size: 100 - quote_lot_size: 1 - is_active: true + + "MARKET" + NVDAX MINT + + USDC MINT ADDRESS + + + market + + authority: Maria + base_mint: NVDAx + quote_mint: USDC + fee_basis_points: 25 + tick_size: 1 + min_order_size: 1 + base_lot_size: 100 + quote_lot_size: 1 + is_active: true diff --git a/building-financial-products-in-solana/figures/order-book-cancel.svg b/building-financial-products-in-solana/figures/order-book-cancel.svg index 3f325fca..716083f9 100644 --- a/building-financial-products-in-solana/figures/order-book-cancel.svg +++ b/building-financial-products-in-solana/figures/order-book-cancel.svg @@ -175,7 +175,7 @@ - + order book program @@ -185,24 +185,26 @@ cancel_order() settle_funds() withdraw_fees() + pause_market() + resume_market() - - "MARKET" + NVDAX MINT + - USDC MINT ADDRESS - - - market - - authority: Maria - base_mint: NVDAx - quote_mint: USDC - fee_basis_points: 25 - tick_size: 1 - min_order_size: 1 - base_lot_size: 100 - quote_lot_size: 1 - is_active: true + + "MARKET" + NVDAX MINT + + USDC MINT ADDRESS + + + market + + authority: Maria + base_mint: NVDAx + quote_mint: USDC + fee_basis_points: 25 + tick_size: 1 + min_order_size: 1 + base_lot_size: 100 + quote_lot_size: 1 + is_active: true diff --git a/building-financial-products-in-solana/figures/order-book-initialize-market.svg b/building-financial-products-in-solana/figures/order-book-initialize-market.svg index 74a7256f..d216f624 100644 --- a/building-financial-products-in-solana/figures/order-book-initialize-market.svg +++ b/building-financial-products-in-solana/figures/order-book-initialize-market.svg @@ -112,7 +112,7 @@ - + order book program @@ -122,25 +122,27 @@ cancel_order() settle_funds() withdraw_fees() + pause_market() + resume_market() - - "MARKET" + NVDAX MINT + - USDC MINT ADDRESS - - - market - - authority: Maria - base_mint: NVDAx - quote_mint: USDC - fee_basis_points: 25 - tick_size: 1 - min_order_size: 1 - base_lot_size: 100 - quote_lot_size: 1 - is_active: true - NEW + + "MARKET" + NVDAX MINT + + USDC MINT ADDRESS + + + market + + authority: Maria + base_mint: NVDAx + quote_mint: USDC + fee_basis_points: 25 + tick_size: 1 + min_order_size: 1 + base_lot_size: 100 + quote_lot_size: 1 + is_active: true + NEW diff --git a/building-financial-products-in-solana/figures/order-book-match.svg b/building-financial-products-in-solana/figures/order-book-match.svg index 9be11d7b..386f5bd4 100644 --- a/building-financial-products-in-solana/figures/order-book-match.svg +++ b/building-financial-products-in-solana/figures/order-book-match.svg @@ -190,7 +190,7 @@ - + order book program @@ -200,24 +200,26 @@ cancel_order() settle_funds() withdraw_fees() + pause_market() + resume_market() - - "MARKET" + NVDAX MINT + - USDC MINT ADDRESS - - - market - - authority: Maria - base_mint: NVDAx - quote_mint: USDC - fee_basis_points: 25 - tick_size: 1 - min_order_size: 1 - base_lot_size: 100 - quote_lot_size: 1 - is_active: true + + "MARKET" + NVDAX MINT + + USDC MINT ADDRESS + + + market + + authority: Maria + base_mint: NVDAx + quote_mint: USDC + fee_basis_points: 25 + tick_size: 1 + min_order_size: 1 + base_lot_size: 100 + quote_lot_size: 1 + is_active: true 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 97f4e54a..20b22d60 100644 --- a/building-financial-products-in-solana/figures/order-book-settle.svg +++ b/building-financial-products-in-solana/figures/order-book-settle.svg @@ -177,7 +177,7 @@ - + order book program @@ -187,24 +187,26 @@ cancel_order() settle_funds() ×2 withdraw_fees() + pause_market() + resume_market() - - "MARKET" + NVDAX MINT + - USDC MINT ADDRESS - - - market - - authority: Maria - base_mint: NVDAx - quote_mint: USDC - fee_basis_points: 25 - tick_size: 1 - min_order_size: 1 - base_lot_size: 100 - quote_lot_size: 1 - is_active: true + + "MARKET" + NVDAX MINT + + USDC MINT ADDRESS + + + market + + authority: Maria + base_mint: NVDAx + quote_mint: USDC + fee_basis_points: 25 + tick_size: 1 + min_order_size: 1 + base_lot_size: 100 + quote_lot_size: 1 + is_active: true diff --git a/building-financial-products-in-solana/figures/order-book-withdraw-fees.svg b/building-financial-products-in-solana/figures/order-book-withdraw-fees.svg index 1bf89099..d4963ecc 100644 --- a/building-financial-products-in-solana/figures/order-book-withdraw-fees.svg +++ b/building-financial-products-in-solana/figures/order-book-withdraw-fees.svg @@ -164,7 +164,7 @@ - + order book program @@ -174,24 +174,26 @@ cancel_order() settle_funds() withdraw_fees() + pause_market() + resume_market() - - "MARKET" + NVDAX MINT + - USDC MINT ADDRESS - - - market - - authority: Maria - base_mint: NVDAx - quote_mint: USDC - fee_basis_points: 25 - tick_size: 1 - min_order_size: 1 - base_lot_size: 100 - quote_lot_size: 1 - is_active: true + + "MARKET" + NVDAX MINT + + USDC MINT ADDRESS + + + market + + authority: Maria + base_mint: NVDAx + quote_mint: USDC + fee_basis_points: 25 + tick_size: 1 + min_order_size: 1 + base_lot_size: 100 + quote_lot_size: 1 + is_active: true 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 99f7b08a..39c0940b 100644 --- a/building-financial-products-in-solana/figures/perps-create-pool.svg +++ b/building-financial-products-in-solana/figures/perps-create-pool.svg @@ -109,7 +109,7 @@ "POOL" + COLLATERAL MINT + ORACLE FEED ADDRESS - + Pool @@ -122,6 +122,8 @@ short_size: 0 cumulative_funding: 0 average_price: $100 + price_feed_program: + mock-price-feed 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 d9b38d1b..e1f3f201 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 @@ -1,4 +1,4 @@ - + Maria opens the market @@ -96,45 +96,47 @@ "MARKET" + NVDAX & USDC MINT ADDRESSES - + market operator: Maria NEW oracle_feed: the NVDAx feed - oracle_scale: 8 (10^8) - base_vault: the NVDAx vault - quote_vault: the USDC vault - base_decimals: 6 - quote_decimals: 6 - spread_bps: 10 - max_confidence_bps: 100 - paused: false + price_feed_program: + mock-price-feed + oracle_scale: 8 (10^8) + base_vault: the NVDAx vault + quote_vault: the USDC vault + base_decimals: 6 + quote_decimals: 6 + spread_bps: 10 + max_confidence_bps: 100 + paused: false - - "BASE_VAULT" + - MARKET'S ADDRESS - - - base vault - - 0 NVDAx - NEW - authority: the market + + "BASE_VAULT" + + MARKET'S ADDRESS + + + base vault + + 0 NVDAx + NEW + authority: the market - - "QUOTE_VAULT" + - MARKET'S ADDRESS - - - quote vault - - 0 USDC - NEW - authority: the market + + "QUOTE_VAULT" + + MARKET'S ADDRESS + + + quote vault + + 0 USDC + NEW + authority: the market diff --git a/building-financial-products-in-solana/lending.md b/building-financial-products-in-solana/lending.md index e11a41e7..abfa80ec 100644 --- a/building-financial-products-in-solana/lending.md +++ b/building-financial-products-in-solana/lending.md @@ -6,10 +6,12 @@ One word is overloaded. This chapter's market trades stocks like NVDAx and SPCXx ## Prices Come In by Signature: The Price Feed, Freshness, and Slots -Every valuation in this chapter multiplies an amount by a price, and the prices come from **price feed** accounts (seeds: `"price_feed"`, the market, the mint) storing a mantissa and exponent. The layout mirrors the account a price feed network publishes: a signed mantissa, an exponent, and the slot the value was written. In production the account would *be* the network's price feed, decoded and verified by the program with the network's own crate. In this example, `set_price()` writes it directly (only the market owner may sign, and the test `non_owner_cannot_write_market_price_feed` checks that), which keeps the tests deterministic and shows the trust arrangement: the program accepts a price because the market owner's private key signed it recently. +Every valuation in this chapter multiplies an amount by a price, and the prices come from **price feed** accounts (seeds: `"price_feed"`, the market, the mint) storing a mantissa, an exponent, and a confidence band. The layout mirrors the account a price feed network publishes: a signed mantissa, an exponent, the publisher's confidence band, and the slot the value was written. The band is how far the publisher's price sources disagree, as half the width of the interval around the price, in the mantissa's units. In production the account would *be* the network's price feed, decoded and verified by the program with the network's own crate. In this example, `set_price()` writes the price, exponent, and band directly; only the market owner may sign, and the test `non_owner_cannot_write_market_price_feed` checks that. Writing it directly keeps the tests deterministic and shows the trust arrangement: the program accepts a price because the market owner's private key signed it recently. "Recently" is enforced, and it is enforced in **slots**: any price older than 25 slots makes the reading handler fail with `StalePriceFeed`, slots being the clock the *Time* chapter recommends whenever an adversary running a leader could profit by shifting the time a few seconds. Every handler that values a reserve requires `refresh_reserve()` to have run in the same slot, and every handler that judges an obligation's health also requires `refresh_obligation()`; in practice both run earlier in the same transaction. A transaction that leaves out the reserve refresh fails with `ReserveStale`, and one that leaves out the obligation refresh fails with `ObligationStale`. +A price the publisher is unsure of is refused too. Each reserve's config sets `max_confidence_bps`, the widest band, as a fraction of the price, the reserve will value against; this market sets 1%. Every read of a price feed checks that `confidence × 10,000 ≤ price × max_confidence_bps`, both sides multiplied out in u128 so no division truncates, and fails with `OracleConfidenceTooWide` when the band is wider. The limit is inclusive: a band of exactly 1% of the price passes, and one unit wider fails. `refresh_obligation()` applies the check to every collateral and borrowed asset it values, and `borrow_obligation_liquidity()`, `withdraw_obligation_collateral()`, and `liquidate_obligation()` apply it to the price feeds they read themselves. A market that has stopped trading, or whose price sources disagree, is not borrowed against, withdrawn from, or liquidated until the publisher posts a narrow band again. + ## The Market Owner Opens the Market Maria runs the market, and her revenue is a cut of the interest, the reserve factor set on every reserve below. She runs `initialize_lending_market()` with USDC as the quote currency, then `set_price()` three times, one price feed per asset, then `initialize_reserve()` three times against them, for USDC, NVDAx, and SPCXx, each with its config. Each reserve's `borrow_accumulation_factor` starts at exactly 1.0, represented as 10^18 in the program's fixed-point scale, chosen so a single second's interest does not truncate to zero while staying far below u128's maximum. The math needs no fixed-point crates: just u128, a scale constant, and two rounding helpers. @@ -32,6 +34,7 @@ Each reserve carries a `ReserveConfig`, all in basis points, and its validation | `close_factor_bps` | 50% | How much of a debt one liquidation may repay. Zero is rejected, because it would make every liquidation a no-op. | | `reserve_factor_bps` | 10% | The house's cut of interest. | | `min/optimal/max_borrow_rate_bps` | 2% / 20% / 150% | The interest curve, kinked at `optimal_utilization_bps` (80%), which must sit strictly inside (0%, 100%). | +| `max_confidence_bps` | 1% | The widest confidence band, as a fraction of the price, the reserve's price feed may report and still be valued against. Zero is rejected, because no live price feed reports a band of zero, so the reserve could never be valued and every obligation holding the asset would be frozen. Above 100% is rejected, because the band is a fraction of the price. | ## Supplying: Tokens In, Shares Out @@ -155,7 +158,7 @@ Every number in this program flows through `mul_div_floor()` or `mul_div_ceil()` - **Debt:** ceiled. - **Health checks:** collateral floored and debt ceiled. -Because every division rounds this way, the market never loses a minor unit to rounding, and `deposit_redeem_round_trip_creates_no_value` checks one round trip at an awkward size. +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. ## The Invariants: What Keeps the Market Solvent @@ -167,16 +170,16 @@ Because every division rounds this way, the market never loses a minor unit to r ## The Failure Modes: The Liquidation Engine Is Only as Good as Its Prices -- **A wrong price makes every number wrong.** Every health check multiplies by the price feed, so a bad price means wrong collateral values, wrong debts, and liquidations that should not happen. This program refuses stale prices (`borrow_with_stale_price_feed_is_rejected`) and lets only the market owner write them (`non_owner_cannot_write_market_price_feed`). Manipulated prices have produced nine-figure losses on real markets (the price-manipulation section of the *Adversary* chapter covers Mango Markets). +- **A wrong price makes every number wrong.** Every health check multiplies by the price feed, so a bad price means wrong collateral values, wrong debts, and liquidations that should not happen. This program refuses stale prices (`borrow_with_stale_price_feed_is_rejected`), refuses prices whose confidence band is wider than the reserve's `max_confidence_bps` (`borrow_against_collateral_priced_with_a_wide_band_is_rejected`, `borrow_of_a_token_priced_with_a_wide_band_is_rejected`), and lets only the market owner write them (`non_owner_cannot_write_market_price_feed`). Manipulated prices have produced nine-figure losses on real markets (the price-manipulation section of the *Adversary* chapter covers Mango Markets). - **Skipping the refresh** would let positions be judged on old values; `ReserveStale`, `ObligationStale`, and `borrow_without_obligation_refresh_is_rejected` close the path. - **Cross-market smuggling**: an obligation in one market pledging reserves from another is refused (`MarketMismatch`, `cross_market_reserve_is_rejected`). -- **Dangerous configurations are rejected at write time**: LTV above threshold, zero close factor, misordered rate curves (`rejects_ltv_above_liquidation_threshold`, `rejects_misordered_interest_rate_curve`). +- **Dangerous configurations are rejected at write time**: LTV above threshold, zero close factor, misordered rate curves, a confidence limit of zero or above the price (`rejects_ltv_above_liquidation_threshold`, `rejects_misordered_interest_rate_curve`, `rejects_zero_confidence_limit`, `rejects_confidence_limit_wider_than_the_price`). - **Share inflation through the reserve's own borrowing.** Donating tokens to the vault moves nothing, because the program prices shares from `available_liquidity`, not the vault balance. But total liquidity also counts interest owed on borrows, and a supplier may borrow from the reserve they supply. The attacker is the only supplier of an empty reserve, holding a single minor unit of its shares, and borrows one minor unit from it against collateral in another reserve. Debt rounds up, so a second of interest makes that debt two minor units, and the attacker's unit of shares is worth two without anything being minted. From there, deposits and redemptions push the share price higher, because both round in the pool's favor and the attacker owns the whole pool. Once that unit is worth about 690 USDC, a 1,000 USDC deposit mints one unit of shares, and the attacker's unit redeems half the pool. `inflating_shares_through_own_borrow_does_not_pay` runs this on the default rate curve, and the attacker repays the loan and leaves no bad debt. Without the withheld shares the attacker turns 4,000 USDC into 4,155.20. With them, the attacker's unit is one of 1,001, and nearly everything the rounding leaves behind goes to shares nobody can redeem. The attacker ends 1,065 minor units down, and the victim gets back the whole deposit. A reserve whose suppliers have all left keeps the 1,000 minor units behind the withheld shares, and the next deposit is priced against them instead of starting the reserve over (`sole_supplier_leaves_the_minimum_behind`). - **Self-liquidation** is allowed, deliberately: it is only possible while unhealthy and is economically pointless. ## 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), liquidation (healthy rejected, unhealthy corrected with bonus and close factor, over-seizure refused), and borrowing (the loan-to-value boundary, a skipped obligation refresh, a stale price, and a price from before a restart). Reserve configuration has its own file, dangerous parameters refused at write time. 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, run fifty times once interest has moved the exchange rate, 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), and liquidation (healthy rejected, unhealthy corrected with bonus and close factor, over-seizure refused). 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. Reserve configuration has its own file, dangerous parameters refused at write time, a confidence limit of zero or above the price among them. The security tests cover cross-market and unauthorized-price attempts. All of it drives the program's own handlers, with each action bundling its refresh instructions into the same transaction as a production client would. ## What You Learnt from Building the Lending Market diff --git a/building-financial-products-in-solana/options.md b/building-financial-products-in-solana/options.md index c46b58b6..f781b831 100644 --- a/building-financial-products-in-solana/options.md +++ b/building-financial-products-in-solana/options.md @@ -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, and `test_zero_fee_venue_pays_the_writer_the_whole_premium` checks that the writer receives all 25 USDC; `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. Her public key is stored as the market's `admin`, and the only handler that reads it is `collect_fees()`. ![](figures/options-open-venue.svg) @@ -135,7 +135,7 @@ Bob calls `buy_option()`. The handler does four things: 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. -A writer cannot buy their own option (`test_writer_cannot_buy_their_own_option`): in the Anchor build, the premium's source and destination would be the same associated token account in two mutable slots, which the runtime rejects before the handler runs. A second buyer is refused because the option is no longer `Listed` (`test_buy_is_refused_once_sold`). +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 Holder Exercises Before Expiry, and the Tokens Change Hands @@ -182,7 +182,7 @@ The week passes with NVDAx above $150, and Dave does nothing: selling at 150 whe ![](figures/options-reclaim.svg) -Carol's return is the 19.80 USDC premium on a purchase that never happened. Dave's insurance cost him 20 USDC and paid nothing. +Carol's return is the 19.80 USDC premium on a purchase that never happened. Dave's insurance cost him 20 USDC and paid nothing. `test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer` follows this path, from Dave's purchase to the reclaim, and checks that the 750 USDC and the option's rent come back to Carol. Had NVDAx fallen below $150, Dave would have exercised instead: his 5 NVDAx into the underlying vault, the 750 USDC out to him, and the shares owed to Carol, who would have bought at $150 what the market then sold for less. @@ -220,7 +220,7 @@ Every checked operation on a counter maps to `MathOverflow`; a vault balance tha - **The admin reaches only the fees.** `collect_fees()` moves `fees_owed` and nothing else; no handler moves collateral or a strike payment to the admin under any parameters. - **Physical settlement moves exactly the posted terms.** What the writer collects is what the holder paid, and what the holder receives is what the writer posted, to the minor unit, for every option the program would accept. -The companion repository's `kani-proofs` crate model-checks the first, the second, and the last. `proof_vault_ledger_stays_consistent_across_every_lifecycle` writes two options of either kind into shared vaults, ends each with a cancel, a reclaim, or an exercise and collection, and shows the ledger consistent after every step and both vaults empty at the end. `proof_exercise_and_reclaim_windows_partition_time` checks the second invariant for every pair of timestamps, and `proof_exercise_moves_exactly_the_posted_terms` checks the last for every option the program would accept. +The companion repository's `kani-proofs` crate model-checks the first, the second, the fourth, and the last. `proof_vault_ledger_stays_consistent_across_every_lifecycle` writes two options of either kind into shared vaults, ends each with a cancel, a reclaim, or an exercise and collection, and shows the ledger consistent after every step and both vaults empty at the end. `proof_exercise_and_reclaim_windows_partition_time` checks the second invariant for every pair of timestamps. `proof_collect_fees_pays_only_the_fees_owed` checks the fourth for every ledger the program could hold: whatever is owed to writers and holders, and whatever surplus sits in the vault beside it, the sweep pays the admin exactly `fees_owed` and leaves nothing for a second sweep to pay. `proof_exercise_moves_exactly_the_posted_terms` checks the last for every option the program would accept. ## The Failure Modes: What Can Go Wrong When Nothing Can Be Liquidated @@ -232,13 +232,13 @@ The companion repository's `kani-proofs` crate model-checks the first, the secon ## Testing Considerations -The suite (`tests/test_options.rs`) follows the put from write to exercise, and the call from purchase to expiry and reclaim, asserting every balance 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: +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. The purchase tests also read the transaction's inner instructions back and count the token transfers a buy makes: two with a fee, one without. 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. - Exercise by anyone but the holder, and collection by anyone but the writer or before an exercise. -- Reclaim after an exercise, and fee collection by anyone but the admin. -- The three zero terms. +- Reclaim after an exercise, fee collection by anyone but the admin, and a second sweep with nothing owed. +- The three zero terms, and a writer buying their own option. 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 be at the one value where `now < expiry` and `now >= expiry` cannot be told apart. @@ -257,6 +257,6 @@ The options venue added six concepts: 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. -[^psyoptions]: PsyOptions ran physically settled, fully collateralized options on Solana, with no oracle, and this chapter's venue follows its settlement design. PsyOptions represented each option series as fungible SPL tokens, one mint per series; this program keeps one account per option instead, for a simpler custody ledger. +[^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 fungible SPL tokens, one mint per series; this program keeps one account per option instead, for a simpler custody ledger. -[^zeta]: Zeta Markets ran a margined, cash-settled options venue on Solana until it wound down in May 2025 and became Bullet, a perpetuals exchange. The comparison is between two settlement designs, not two products: a cash-settled venue can offer leverage and needs a price feed to do it, and a physically settled one offers neither. +[^zeta]: Zeta Markets ran a margined, cash-settled options venue on Solana, later a perpetual futures one, and wound down on 1 May 2025 as its team moved to Bullet, a trading network of its own; see [x.com/ZetaMarkets/status/1908072806765735956](https://x.com/ZetaMarkets/status/1908072806765735956) and [docs.zeta.markets](https://docs.zeta.markets). The comparison is between two settlement designs, not two products: a cash-settled venue can offer leverage and needs a price feed to do it, and a physically settled one offers neither. diff --git a/building-financial-products-in-solana/order-book.md b/building-financial-products-in-solana/order-book.md index 49d78b34..1055bec6 100644 --- a/building-financial-products-in-solana/order-book.md +++ b/building-financial-products-in-solana/order-book.md @@ -28,7 +28,7 @@ A critbit tree does not balance itself, so an attacker can place asks at 2, 4, 8 The market this chapter builds trades NVDAx over USDC: NVDAx, a stock, is the **base token**, the thing being bought and sold; USDC is the **quote token**, the thing prices are stated in. You quote in the stable asset so that "250" always means dollars. -Maria runs the exchange. She is the market authority: she creates the market and she alone can 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. So the side of the fill that receives the quote token bears it: the maker when the taker is a bid, the taker when the taker is an ask. +Maria runs the exchange. She is the market authority: she creates the market, and she alone can pause it, resume it, and withdraw its fees. Her revenue is a fee of 25 basis points, a quarter of one percent, deducted from each fill's quote proceeds when orders match. So the side of the fill that receives the quote token bears it: the maker when the taker is a bid, the taker when the taker is an ask. Maria'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. @@ -40,7 +40,7 @@ Both are set per market, at creation, and cannot change afterward. A tick right Opening the market created five accounts: one by Maria's client, and four by the handler. -The **market** account holds one trading pair's configuration: the two mint addresses, the fee in basis points, the tick size, the lot sizes, the minimum order size, and the authority (Maria). It lives at a PDA whose seeds are the string `"market"` plus the two mints, so one base/quote pair maps to one market. The market PDA is also the authority of all three vaults, so tokens leave them only through the program's own instruction handlers. +The **market** account holds one trading pair's configuration: the two mint addresses, the fee in basis points, the tick size, the lot sizes, the minimum order size, the authority (Maria), and an `is_active` flag that `initialize_market()` sets. It lives at a PDA whose seeds are the string `"market"` plus the two mints, so one base/quote pair maps to one market. The market PDA is also the authority of all three vaults, so tokens leave them only through the program's own instruction handlers. The **order book** is the largest account: about 180 KB holding two trees of resting orders (one per side, 512 orders each) plus a monotonic `next_order_id` counter. Each tree has 1,024 nodes, and every order after the first takes two of them: a leaf for the order and a branch point where its key splits from the others. It is **zero-copy**: an account this large would use too many compute units to deserialize on every instruction, so the program reads and writes fields in place through an `AccountLoader` instead. @@ -60,6 +60,8 @@ Alice holds NVDAx and thinks AI is overhyped. She wants out, but not at any pric The handler validates first: the market is active, the price is nonzero and tick-aligned, the quantity meets the minimum, and Alice is under her 20-open-order cap. Then it takes her tokens up front. An ask pays in the base token: her ten shares move immediately from her token account into the base vault. (A bid pays in the quote token instead: price times quantity.) Because the tokens move before the order rests, every order on the book is backed by tokens in a vault; there is no path where a matched order turns out to be backed by nothing. +The market is active unless Maria has paused it. `pause_market()` clears the market's `is_active` flag, so `place_order()` fails with `MarketPaused` until `resume_market()` sets it again, and nothing else changes, because `cancel_order()`, `settle_funds()`, and `withdraw_fees()` never read the flag. A pause stops new orders without stopping anyone getting their tokens out. + The book's ask side is empty and no bid reaches 265, so there is nothing to match. Alice's order becomes a **resting order**: a leaf in the ask tree, an order account stamped Open, and an entry in her open-order list. Placing it cost her nothing but rent and the transaction fee; the exchange's own fee only ever applies to fills. ![](figures/order-book-alice-ask.svg) @@ -138,7 +140,8 @@ At any time, Maria calls `withdraw_fees()`, and the fee vault's balance (1.875 U - **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, and rejects with `MakerAccountMismatch`, `MakerOwnerMismatch`, or `MissingMakerAccounts`. - **The rounding drain.** A floored fee would leak a minor unit on every fill; the fee ceils, 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 slot, and the evicted owner is refunded through their unsettled balance, exactly 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, rejecting anything else with `EvictedAccountMismatch`. Tick alignment, the minimum order size, and the 20-open-orders-per-user cap still apply, so every slot ties up its occupant's tokens in a vault. -- **The unauthorized paths.** Canceling someone else's order rejects with `Unauthorized`; withdrawing fees without being the authority rejects with `NotMarketAuthority`. +- **A pause that traps tokens.** A flag that stopped every handler would let the authority hold every resting order's tokens and every unsettled balance in the vaults for as long as she liked. Here the flag stops only `place_order()`: `paused_market_still_cancels_and_settles_a_resting_order` cancels and settles an order while the market is paused, and `paused_market_still_pays_out_fills_and_withdraws_fees` settles a fill and withdraws the fees while it is paused. +- **The unauthorized paths.** Canceling someone else's order rejects with `Unauthorized`; withdrawing fees, pausing, or resuming without being the authority rejects with `NotMarketAuthority`. ## Testing Considerations @@ -150,13 +153,14 @@ The suite in `tests/test_order_book.rs` builds all state through the program's o - Fees and payouts: `fee_vault_receives_exactly_bps_of_taker_gross`, `fee_rounds_up_when_gross_is_not_a_bps_multiple`, `cancel_and_settle_bid_refunds_full_quote`, and `settle_funds_after_match_pays_out_both_unsettled_balances`. - The adversarial cases: `settle_funds_rejects_fee_vault_substituted_for_quote_vault`, `cancel_order_rejects_non_owner`, and `authority_can_withdraw_fees_after_match`. - The tree's worst case: `doubling_prices_build_the_deepest_path_prices_allow` and `deepest_path_adds_little_compute_to_insert_fill_and_cancel`. +- The pause: `pause_market_refuses_new_orders_with_market_paused`, `paused_market_still_cancels_and_settles_a_resting_order`, `paused_market_still_pays_out_fills_and_withdraws_fees`, `resume_market_accepts_orders_again`, and `only_the_market_authority_can_pause_or_resume`. - 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. Two accounts the token program cannot tell apart, but your accounting must, are where this bug appears; test that your program tells them apart. ## What You Learnt from Building the Order Book Exchange -NYSE, NASDAQ, and the CME each run a matching engine, a clearing house, and a settlement system, staffed and audited. This program's matching engine is two critbit trees holding the book in price-time order, its clearing house's internal ledger is the unsettled balances, and its settlement is `settle_funds()`, which any user runs for their own balances, with no operator involved. The one operator who remains sets the market's parameters and collects its fee, and can do nothing else. +NYSE, NASDAQ, and the CME each run a matching engine, a clearing house, and a settlement system, staffed and audited. This program's matching engine is two critbit trees holding the book in price-time order, its clearing house's internal ledger is the unsettled balances, and its settlement is `settle_funds()`, which any user runs for their own balances, with no operator involved. The one operator who remains sets the market's parameters, collects its fee, and can stop new orders arriving; she can do nothing else, and no handler of hers keeps a trader's tokens in a vault. The order book added six concepts on top of the escrow's: diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index 6692d281..dc2f2ba6 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -63,8 +63,9 @@ 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 one **price feed** account at creation, the mechanism from the *Offchain Truth* chapter. 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 six checks on what it reads, in order, each for the reason the *Offchain Truth* chapter gave: +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 one **price feed** account at creation, and the program that owns that account (`price_feed_program`, read from the account's owner at that moment), the mechanism from the *Offchain Truth* chapter. 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, each for the reason the *Offchain Truth* chapter gave: +- 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`), - the price is positive (`NonPositivePrice`), - the price feed's decimal scale equals the one the pool recorded at creation (`OracleScaleMismatch`), so a price feed migration cannot silently shift every price by powers of ten, @@ -72,11 +73,11 @@ Every trading and liquidity handler begins the same way: read the oracle, valida - the price was stamped after the last cluster restart (`PricePredatesRestart`), the check the *Time* chapter asks of every freshness window, - and the price feed's confidence band, divided by the price, is within `max_confidence_bps` (`OracleConfidenceTooWide`). -With leverage, a stale price lets anyone trade this market against a live one elsewhere at no risk. The tests `test_stale_price_rejected`, `test_open_rejects_price_from_before_a_restart` and `test_wide_oracle_confidence_rejected` check the last three refusals. +With leverage, a stale price lets anyone trade this market against a live one elsewhere at no risk. The tests `test_open_rejects_price_feed_from_another_program`, `test_stale_price_rejected`, `test_open_rejects_price_from_before_a_restart` and `test_wide_oracle_confidence_rejected` check the first refusal and the last three. ## The Pool Averages the Price Itself, and Trades Only Near the Average -The six checks refuse a price 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 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 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: @@ -115,7 +116,7 @@ The example documents two simplifications: - **The pool is the other side of every funding payment.** The heavier side pays the pool, and the pool pays the lighter side at the same rate out of `liquidity`, rather than the two sides paying each other. - **The rate is flat**, rather than scaling with how lopsided the book is. -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 everything the long paid in funding went to `liquidity`; `test_operator_on_the_lighter_side_earns_only_the_fixed_rate` checks the exact amount. 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. +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, so what a position costs per hour is the rate alone, whatever the network's slot length. 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. @@ -193,9 +194,9 @@ The book's standing invariants hold here too. The vault balance always equals `l ## 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 six checks, the confidence cap, 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 and moving only as fast as the average follows. +- **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, the confidence cap, 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 and moving only as fast as the average follows. - **A position gapped through zero**: the waterfall above (checked by `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity`). -- **First-depositor share inflation**: a donation to the vault moves nothing, because AUM reads `liquidity`, not the vault balance. But `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, because it counts as 1,000 shares in both divisors: the attacker's share is 1 of 1,001, and almost everything they pay in stays with the locked shares. A deposit mints deposit × (shares outstanding + 1,000) ÷ AUM. 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 500 minor units, under `MINIMUM_LIQUIDITY`, is refused. +- **First-depositor share inflation**: a donation to the vault moves nothing, because AUM reads `liquidity`, not the vault balance. But `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, because it counts as 1,000 shares in both divisors: the attacker's share is 1 of 1,001, and almost everything they pay in stays with the locked shares. A deposit mints deposit × (shares outstanding + 1,000) ÷ AUM. 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. - **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. diff --git a/building-financial-products-in-solana/prop-amm.md b/building-financial-products-in-solana/prop-amm.md index 800bdd65..a1704f2a 100644 --- a/building-financial-products-in-solana/prop-amm.md +++ b/building-financial-products-in-solana/prop-amm.md @@ -23,7 +23,7 @@ Maria calls `initialize_market()` with a spread of 10 basis points and a 1% cap 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 and its scale, both fixed at creation, 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** 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: the spread is the fee, and it 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. @@ -37,7 +37,7 @@ Then `deposit_inventory()` moves the 1,000 NVDAx and 200,000 USDC from the opera Alice, carrying the standard wallet, wants 5 NVDAx. She calls `swap()` with the direction `BuyBase`, and the handler prices her in three steps: -- **The oracle read**, with the checks from the *Offchain Truth* chapter: the price feed's layout, a positive price, the scale fixed at creation, freshness within 150 slots, and a confidence band inside the market's limit. It also refuses a price stamped at or before the last cluster restart (`PricePredatesRestart`), for the reason the *Time* chapter gives. +- **The oracle read**, with the checks from the *Offchain Truth* chapter. First, before a byte of it is decoded, the price feed account must be owned by the program the market recorded at creation (`PriceFeedNotFromOracle`), so an account laid out like a price feed but written by another program is never a price. Then the price feed's layout, a positive price, the scale fixed at creation, freshness within 150 slots, and a confidence band inside the market's limit. It also refuses a price stamped at or before the last cluster restart (`PricePredatesRestart`), for the reason the *Time* chapter gives. - **The ask**: $165 plus 10 basis points is $165.165, **rounded up** in fixed point, because the buyer's price rounds against the buyer. - **Her output**, floored: 825.825 USDC of her 1,000 buys exactly 5 NVDAx. @@ -108,12 +108,12 @@ The unit conversion is the only complicated part. Prices arrive at the oracle's - **The stale quote.** The catastrophic one, and it needs no exploit, just an oracle that lags a moving market. Every fill against a wrong quote is pure profit for the counterparty and pure loss for Maria, risk-free; `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.** Slower and subtler: even a fresh oracle trails the true price by seconds, and informed flow captures the difference. 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 operator chooses which oracle to trust.** The operator picks the price feed at creation, and the program deliberately does not verify the price feed account's owner, the same visible trust decision the *Perpetual Futures* chapter makes, with one difference: a bad oracle here loses *Maria's* capital, not her users'. +- **The operator chooses which oracle to trust.** The operator picks the price feed at creation; the market records the program that owns it and refuses a price feed account from any other program (`PriceFeedNotFromOracle`, checked by `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, with one difference: a bad oracle here loses *Maria's* capital, not her users'. - **What is absent.** Run the *Adversary* chapter's checklist against this program and whole categories return "not applicable". 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. ## 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: 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 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. ## What You Learnt from Building the Prop AMM diff --git a/financial-programming-concepts-in-solana/offchain-truth.md b/financial-programming-concepts-in-solana/offchain-truth.md index 04073bc0..a0c1d202 100644 --- a/financial-programming-concepts-in-solana/offchain-truth.md +++ b/financial-programming-concepts-in-solana/offchain-truth.md @@ -14,12 +14,12 @@ The third is **signed offchain and carried in your transaction**: the trusted pa 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 exact price feed recorded in its registry. The lending market's example price feed is a test stand-in written by `set_price()`, which only a transaction signed with the market owner's private key may run; in production the reserve points at a Pyth `PriceUpdateV2` account, the second shape. +- **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 exact price feed recorded in its registry. The perpetual futures exchange 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. The lending market's example price feed is a test stand-in written by `set_price()`, which only a transaction signed with the market owner's private key may run; in production the reserve points at 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 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 perpetual futures exchange and prop AMM refuse one wider than the pool's `max_confidence_bps`. A fresh price with a wide confidence interval is a separate failure mode from a stale one. +- **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 exchange 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**, the free price feeds that the managed fund reads and the other programs stand in for with a mock, is signed by Pyth's guardian set: five private keys held by Pyth, any three of whose signatures make a valid update.[^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 three of the guardians 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 is the program that rejects a wide `conf` and a wrong price feed address. +> **How a Pyth price reaches your program.** **Pyth Core**, the price feed accounts that the managed fund reads and the other programs stand in for with a mock, 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 is the program that rejects a wide `conf` and a wrong price feed address. > > **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. @@ -47,6 +47,6 @@ 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 replaced them with five private keys of its own, three of which must sign, in proposal OP-PIP-131, and the Pyth Receiver program and the `PriceUpdateV2` account kept their names and layout. +[^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/the-adversary.md b/financial-programming-concepts-in-solana/the-adversary.md index 76aa68ff..b9fa67a4 100644 --- a/financial-programming-concepts-in-solana/the-adversary.md +++ b/financial-programming-concepts-in-solana/the-adversary.md @@ -64,7 +64,7 @@ A program needs at least one of these four, chosen on purpose, and has to check The checks in the *Offchain Truth* chapter (publisher, freshness, confidence) reject a price that is fake or stale. This attack gets past all of them. The price is real, fresh, and correctly signed, and it accurately reports a market the attacker has just pushed. Push a thin market's price up for one slot, and every program that reads it acts on it. Lending markets extend credit against collateral that looks more valuable than it is. Exchanges settle at a price nobody could trade at a minute later. Liquidations fire on positions that were healthy one slot before. -> **War Story: Mango Markets, $116M, 2022.** An attacker opened large perpetual positions in Mango's own thinly traded MNGO token. They then bought MNGO on the spot markets the oracle read from, pushing its price up more than thirteen-fold in about half an hour. The oracle reported the pushed price accurately. Mango's program used it to mark the attacker's positions as hugely profitable, and the attacker borrowed $116M of the platform's deposits against that unrealized profit and left.[^mango] The attacker described it as "a highly profitable trading strategy." No signature was forged and no account was substituted. +> **War Story: Mango Markets, $116M, 2022.** An attacker opened large perpetual positions in Mango's own thinly traded MNGO token. They then bought MNGO on the spot markets the oracle read from, pushing the price the oracle reported from about four cents to fifty-four, more than thirteen-fold, in about half an hour. The oracle reported the pushed price accurately. Mango's program used it to mark the attacker's positions as hugely profitable, and the attacker borrowed $116M of the platform's deposits against that unrealized profit and left.[^mango] The attacker described it as "a highly profitable trading strategy." No signature was forged and no account was substituted. Programs handle this by making manipulation expensive and slow: @@ -77,7 +77,7 @@ Programs handle this by making manipulation expensive and slow: The *Arithmetic of Money* chapter set the rounding rules: round against the user, floor once, and round debt 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. Then they redeemed shares in amounts chosen so the floored number of shares burned came out one short, again and again, until the pool was empty. 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. 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. Fix this by following the rounding rules in the *Arithmetic of Money* chapter 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` is a dozen lines. @@ -120,7 +120,7 @@ Where the role is the one reporting a result, like the betting market's admin se [^mango]: Both US regulators charged it, and their filings describe the trade in detail: the CFTC at [cftc.gov/PressRoom/PressReleases/8647-23](https://www.cftc.gov/PressRoom/PressReleases/8647-23), the SEC at [sec.gov/newsroom/press-releases/2023-13](https://www.sec.gov/newsroom/press-releases/2023-13). The phrase quoted above is the attacker's own, posted on 15 October 2022 at [x.com/avi_eisen/status/1581326197241180160](https://x.com/avi_eisen/status/1581326197241180160) and reproduced in the criminal complaint. -[^hundred-sonne]: Hundred Finance is 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). +[^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. 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 054ed7ef..00d99351 100644 --- a/financial-programming-concepts-in-solana/the-arithmetic-of-money.md +++ b/financial-programming-concepts-in-solana/the-arithmetic-of-money.md @@ -53,7 +53,7 @@ Integer division nearly always leaves a remainder, and that remainder is **dust* 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. -> **War Story: the SPL token-lending rounding bug.** In 2021, security researchers at Neodyme found that the widely-forked SPL token-lending program rounded a conversion with `round`, to the nearest integer, instead of flooring it. 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 that shipped was one word: `round` became `floor`. +> **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 @@ -102,7 +102,7 @@ The rate itself is a policy choice; the arithmetic above only applies whatever r - 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 undercharges by more the longer the market sits untouched, so a market without a refresh schedule needs exponentiation by squaring. -[^spl-lending]: Neodyme published the bug, the exploit, and the coordinated disclosure in December 2021, at [neodyme.io/en/blog/lending_disclosure](https://neodyme.io/en/blog/lending_disclosure/). The $2.6B is the total value locked across the forks at the time, not a loss: the fix shipped before anyone else found it. +[^spl-lending]: Neodyme published the bug, the exploit, and the coordinated disclosure in December 2021, at [neodyme.io/en/blog/lending_disclosure](https://neodyme.io/en/blog/lending_disclosure/). The $2.6B is Neodyme's figure for the total value at risk across the forks, not a loss: the fix shipped before anyone else found it, as pull request 1883 to the Solana Program Library, at [github.com/solana-labs/solana-program-library/pull/1883](https://github.com/solana-labs/solana-program-library/pull/1883). [^fixed-point-formats]: Each format is defined in its program's source: SPL token-lending's `WAD` in [github.com/solana-labs/solana-program-library/blob/master/token-lending/program/src/math/common.rs](https://github.com/solana-labs/solana-program-library/blob/master/token-lending/program/src/math/common.rs), spl-math's `PreciseNumber` in [github.com/solana-labs/solana-program-library/blob/master/libraries/math/src/precise_number.rs](https://github.com/solana-labs/solana-program-library/blob/master/libraries/math/src/precise_number.rs), and Kamino's `Fraction`, the `U68F60` type from the `fixed` crate, in [github.com/Kamino-Finance/klend/blob/master/programs/klend/src/utils/fraction.rs](https://github.com/Kamino-Finance/klend/blob/master/programs/klend/src/utils/fraction.rs). SPL token-lending keeps its general values in `Decimal`, a wad held in a 192-bit integer, in [github.com/solana-labs/solana-program-library/blob/master/token-lending/program/src/math/decimal.rs](https://github.com/solana-labs/solana-program-library/blob/master/token-lending/program/src/math/decimal.rs). All four were read on October 1, 2026. diff --git a/prelude/introduction.md b/prelude/introduction.md index 76f7dc79..1e8901c6 100644 --- a/prelude/introduction.md +++ b/prelude/introduction.md @@ -10,7 +10,7 @@ Financial products built on Solana have properties no traditional financial syst - **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. -- **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. Sending $200 across a border costs about $13 on average, and more than $40 at a Western Union or MoneyGram cash counter in some parts of the world.[^remittance-scope] A flat fee makes it worth sending payments that were too small to bother with. +- **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] - **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] @@ -92,7 +92,9 @@ 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). -[^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 near 6.5%. Source: The World Bank, [remittanceprices.worldbank.org](https://remittanceprices.worldbank.org). +[^card-fees]: A United States café's all-in acceptance cost in 2026: the processor's margin on top of interchange. Visa's published US interchange alone, effective 18 April 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). [^after-hours]: Allium measured 63% of Solana's tokenized-equity volume in the twelve months to August 18, 2026 as traded while US exchanges were closed, and 17% of it on weekends. See [allium.so/solana](https://allium.so/solana). diff --git a/scripts/check-figure-geometry.mjs b/scripts/check-figure-geometry.mjs index 9ec0b7b1..aa7978c1 100644 --- a/scripts/check-figure-geometry.mjs +++ b/scripts/check-figure-geometry.mjs @@ -529,7 +529,6 @@ for (const file of selected) { // box it enters, or threads a gap between columns: no arc can arrive within the angle limit, and the top // edge carries the box's seed label. Each needs a layout change, which is separate work. "building-financial-products-in-solana/figures/betting-alice-claim.svg 118,141 slides", - "building-financial-products-in-solana/figures/betting-place-bets.svg 470,611 slides", "building-financial-products-in-solana/figures/lending-deposit-shares.svg 179,670 slides", "building-financial-products-in-solana/figures/lending-deposit-shares.svg start 97,183 corner", "building-financial-products-in-solana/figures/lending-liquidation.svg 470,692 slides", diff --git a/scripts/lint-manuscript.mjs b/scripts/lint-manuscript.mjs index 6efe529e..e7c1b768 100644 --- a/scripts/lint-manuscript.mjs +++ b/scripts/lint-manuscript.mjs @@ -217,7 +217,7 @@ const rules = [ // handler's one gate" is "the claim handler compares", "the two exits" is // "the claim or a refund", "state is truth" is the field the handler reads. // Solana's feature gates are the real term and stay. - [/(?