diff --git a/building-financial-products-in-solana/betting-market.md b/building-financial-products-in-solana/betting-market.md index 45f3fc09..dff880c5 100644 --- a/building-financial-products-in-solana/betting-market.md +++ b/building-financial-products-in-solana/betting-market.md @@ -14,28 +14,30 @@ In a bookmaker market, the house quotes odds and carries risk on every bet. In a ## The Operator Creates the Market -Maria is the admin. Her key is recorded in the program's config, and only she can create events, add outcomes, 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"), then `add_outcome()` twice: "Toy Story 5" and "Backrooms". Every one of these instruction handlers checks her signature against the admin address stored in the config and rejects anyone else with `Unauthorized`; the test `test_only_admin_can_initialize_event` checks it. +Maria is the admin. Her 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: "Toy Story 5" and "Backrooms". Then she calls `open_betting()`, which moves the event to Open. Every one of these instruction handlers checks her signature against the admin address stored in the config and rejects anyone else with `Unauthorized`; the test `test_only_admin_can_initialize_event` checks it. ![](figures/betting-maria-setup.svg) -Financial state on Solana is accounts, and those four transactions created the five the market needs. +Financial state on Solana is accounts, and those five transactions created the five accounts the market needs. The **config** is a single PDA (seed `"config"`) created once by `initialize_config()`, holding the `admin`, the `token_mint` bets are denominated in, the `fee_recipient`, the `default_fee_bps` each new event copies at creation, and a running `event_count`. The fee is capped at 10,000 basis points, because a fee above 100% is nonsense. -The **event** is a PDA (seeds: `"event"`, the event ID) with a description, a status that is Open, Settled, or Cancelled, the running `total_pool`, and, after settlement, the `winning_outcome_index`, `winning_pool`, and `distributable_losing_pool`. Two design details: +The **event** is a PDA (seeds: `"event"`, the event ID) with a description, a status (Draft, Open, Settled, or Cancelled), the `betting_closes_at` time when betting stops, and the running `total_pool`. After settlement it also holds the `winning_outcome_index`, `winning_pool`, and `distributable_losing_pool`. Two design details: -- **Each event keeps the fee it was created with.** If Maria later changes `default_fee_bps`, markets that bettors already joined keep the fee they signed up under. Terms do not change retroactively. +- **Each event keeps the terms it was created with.** Its `fee_bps` is copied from the config's `default_fee_bps`, and `betting_closes_at` is set once by `initialize_event()`. If Maria later changes `default_fee_bps`, markets that bettors already joined keep the fee they signed up under. - **The vault is one token account for the whole event**, an associated token account whose authority is the event PDA. Stakes on every outcome mix in one vault; the accounting that keeps them straight lives in the data accounts, not in separate pots. Each **outcome** is a PDA (seeds: `"outcome"`, the event, an index byte) holding its label, its `total_amount` staked, and its `bet_count`. -One rule protects the bettors: **the outcome list locks the moment betting starts**. `add_outcome()` fails with `BettingAlreadyStarted` once any money is in the pool, so the field of choices cannot change out from under someone who already bet. +One rule protects the bettors: **the outcome list is fixed before any money can arrive**. `add_outcome()` works only on a Draft and fails with `EventNotDraft` once the event is Open. `place_bet()` on a Draft fails with `EventNotOpen`, so nobody can bet while the list is still changing. `open_betting()` refuses a market with fewer than two outcomes (`NotEnoughOutcomes`), because a single outcome has no losing side to pay its winners from. The tests `test_outcomes_lock_when_betting_opens` and `test_open_betting_needs_two_outcomes` check all three rules. + +The Draft status is what makes the rule hold. The simpler design locks the list at the first bet, by refusing `add_outcome()` once the pool holds any money. That lets anyone freeze a half-built market with a one-minor-unit bet on its only outcome. It also lets Maria's last `add_outcome()` and the first bet land in either order, so the first bettor can stake on a list with an outcome their screen never showed. An explicit status that only Maria can change has neither problem. ## Bettors Stake on Their Chosen Outcomes Three bettors take a view, each from the standard wallet of one SOL and 1,000 USDC, and all in USDC, the token the config designates. Alice is confident about school holidays and puts $300 on Toy Story 5. Bob agrees but has less to spend: $100 on Toy Story 5. Carol believes in word of mouth and puts $200 on Backrooms. -Each bettor calls `place_bet()` with an amount. Zero-amount bets reject with `ZeroAmount`. The handler moves the stake immediately from the bettor's token account into the event's vault (the bettor signs this transfer directly; no PDA signature is needed on the way in). It then creates the bet account if this is the bettor's first bet on this outcome, and bumps the outcome's `total_amount` and the event's `total_pool`. A fresh bet account has amount zero, which is how the handler tells a first bet from a top-up and gates the per-outcome bookkeeping. +Each bettor calls `place_bet()` with an amount, any time before the close. Zero-amount bets reject with `ZeroAmount`, and a bet once the clock reaches `betting_closes_at` rejects with `BettingClosed`. The handler moves the stake immediately from the bettor's token account into the event's vault (the bettor signs this transfer directly; no PDA signature is needed on the way in). It then creates the bet account if this is the bettor's first bet on this outcome, and bumps the outcome's `total_amount` and the event's `total_pool`. A fresh bet account has amount zero, which is how the handler tells a first bet from a top-up and gates the per-outcome bookkeeping. After all three bets, the vault holds 600 USDC: 400 staked on Toy Story 5, 200 on Backrooms. @@ -45,7 +47,9 @@ A bettor's first bet also created two accounts for them. Each **bet** is a PDA ( ## The Film Grosses: The Operator Settles the Event -The month ends. In the real world, Toy Story 5 wins. In program terms, the fact that decides where 600 USDC goes exists entirely offchain. This program resolves it by having a designated trusted party sign. Maria calls `settle_event()` with the winning outcome's index, her signature is checked against the config's admin, and her word becomes the onchain truth. The *Offchain Truth* chapter covers the full spectrum, from a single admin key like this one, through multi-party resolution, to signed price feeds. This program makes the trusted party explicit and limits what their signature can do. Maria can pick which outcome won, but she cannot touch the pro-rata math, take more than the snapshotted fee, or reach into the vault by any other path. +The month ends, and betting closes at midnight. In the real world, Toy Story 5 wins. In program terms, the fact that decides where 600 USDC goes exists entirely offchain. This program resolves it by having a designated trusted party sign. Maria calls `settle_event()` with the winning outcome's index, her signature is checked against the config's admin, and her word becomes the onchain truth. The *Offchain Truth* chapter covers the full spectrum, from a single admin key like this one, through multi-party resolution, to signed price feeds. This program makes the trusted party explicit and limits what their signature can do. Maria can pick which outcome won, but she cannot touch the pro-rata math, take more than the snapshotted fee, or reach into the vault by any other path. + +`settle_event()` works only once the clock reaches `betting_closes_at`, and earlier fails with `BettingStillOpen`. The two windows split time exactly: a bet needs the clock before the close, and settlement needs it at or after. So no bet can land once the event can be settled, and Maria cannot end the market early (checked by `test_betting_closes_at_close_time`). Settlement does four things, none of which involve paying bettors yet: @@ -79,7 +83,7 @@ There is no `claimed` boolean to forget to check, because the account's deletion Carol's losing bet pays nothing, but it still occupies one of her 32 position slots and holds 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 cheaper path rejects with `BetWon` and must use `claim_winnings()`), then closes the account and frees the slot. Both rules are checked by `test_close_losing_bet_only_after_settle_and_only_for_losers` and `test_closing_a_bet_frees_a_slot_for_a_new_bet`. -And what if the film festival burns down? An Open event has a second exit: `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`). Open, Settled, Cancelled: three states, and every one of them has an exit for every participant's money. +And what if the film festival burns down? An Open event has a second exit: `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 money in it. Draft, Open, Settled, Cancelled: four states, and every one of them has an exit for every participant's money. ![](figures/betting-state-machine.svg) @@ -92,7 +96,7 @@ The financial arithmetic of this program is four expressions: - **Each winner's share**: a u128 multiply of stake by distributable pool, then one floor division by the winning pool. - **The payout**: stake plus share, checked. -Every checked operation maps to `MathOverflow`. There are no rates, no time, no curves; the betting market shows the arithmetic rules with nothing else in the way. +Every checked operation maps to `MathOverflow`. There are no rates and no curves, and time appears only as one comparison against the close; the betting market shows the arithmetic rules with nothing else in the way. ## The Invariants: The Vault Equals the Promises @@ -100,6 +104,7 @@ Every checked operation maps to `MathOverflow`. There are no rates, no time, no - The winning pool, losing pool, and fee always sum exactly to the pre-settlement total pool. - 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. +- 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 pro-rata formula decides amounts. ## The Failure Modes: The Oracle Is a Person, and Everything Downstream of That @@ -107,13 +112,14 @@ Every checked operation maps to `MathOverflow`. There are no rates, no time, no - **The resolution itself.** Maria could settle the wrong outcome, maliciously or by mistake. The program limits the damage to that one decision (she cannot skim beyond the fee or redirect funds), but 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 grow out of a single key. - **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. There is no flag to desynchronize. -- **Changing the game mid-play**: adding outcomes after betting starts is rejected; the fee is snapshotted per event. +- **Changing the game mid-play**: `open_betting()` fixes the outcome list before any bet can land, and the fee and close time are fixed per event at creation. +- **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. - **Slot exhaustion**: 32 concurrent positions per user, `TooManyBets` beyond it, and `close_losing_bet()` to recycle slots. -- **Betting after the event closes** rejects with `EventNotOpen`, whichever exit the event took. +- **Betting after settlement or cancellation** rejects with `EventNotOpen`, whichever exit the event took. ## Testing Considerations -The suite builds all state through the program's own handlers, and its centerpiece is `test_full_lifecycle`: the exact story above, config through claims, with balances asserted at each step. Around it, one test per failure mode: admin gating, bet-after-settle, double claims, empty-outcome settlement, cancel-and-refund, and both rules of losing-bet closure. When you write your own market, copy the shape: the happy path proves 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`: the exact story above, config through claims, with balances asserted at each step. Around it, one test per failure mode: admin gating, 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, cancel-and-refund, and both rules of losing-bet closure. When you write your own market, copy the shape: the happy path proves 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 @@ -123,8 +129,8 @@ This chapter added five concepts: - **The parimutuel structure.** All stakes pool together; winners split the losing pool pro rata by stake, so conviction pays proportionally and no one sets odds. - **Offchain truth by designated signer.** The operator's signature is the oracle, and the program's job is making that trust visible and narrow: they pick the winner, but cannot touch the math, the snapshotted fee, or the vault by any other path. -- **Terms locked when money commits.** The outcome list freezes at the first bet and the fee is snapshotted at event creation, so the question bettors staked on cannot be rewritten. +- **Terms locked when money commits.** An explicit Draft status keeps bets out until the admin opens the market, so the outcome list is fixed before the first stake. The fee and close time are fixed at event creation, so the question bettors staked on cannot be rewritten. - **Account closure as the double-claim defense.** A claimed bet's account is gone, so there is no flag to desynchronize; the state machine's exit is the account's deletion. -- **An exit from every state.** Open events can be canceled and refunded in full, so a burned-down film festival strands nobody's stake. +- **An exit from every state.** Draft and Open events can be canceled, and stakes in a canceled event are refunded in full, so a burned-down film festival strands nobody's stake. 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 locked when the order is placed, and the program matches each new order against the best resting one. You will see how orders are stored, matched, and paid out onchain, where every step costs compute. The pooled payout formula and the trusted signer both return later, in the *Lending and Borrowing* and *Perpetual Futures* chapters, where the trusted signature is a price feed and settlement runs continuously instead of once. diff --git a/building-financial-products-in-solana/figures/betting-account-map.svg b/building-financial-products-in-solana/figures/betting-account-map.svg index 4228d9d7..de16a576 100644 --- a/building-financial-products-in-solana/figures/betting-account-map.svg +++ b/building-financial-products-in-solana/figures/betting-account-map.svg @@ -158,73 +158,75 @@ - + betting program initialize_config() - create_event() + initialize_event() add_outcome() - place_bet() - settle_event() - claim_winnings() - close_losing_bet() - cancel_event() - claim_refund() - - - - "CONFIG" - - - config - - admin: Maria - token_mint: USDC - fee_recipient: Maria - default_fee_bps: 200 (2%) - - - - "EVENT" + EVENT ID - - - event - - status: Open - total_pool: 600 - fee_bps: 200 - - - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 0: Toy Story 5 - - total_amount: 400 - - - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 1: Backrooms - - total_amount: 200 - - - - EVENT'S ADDRESS + - TOKEN PROGRAM + USDC MINT - - - vault: the event's USDC - - 600 USDC - authority: the event PDA + open_betting() + place_bet() + settle_event() + claim_winnings() + close_losing_bet() + cancel_event() + claim_refund() + + + + "CONFIG" + + + config + + admin: Maria + token_mint: USDC + fee_recipient: Maria + default_fee_bps: 200 (2%) + + + + "EVENT" + EVENT ID + + + event + + status: Open + total_pool: 600 + fee_bps: 200 + betting_closes_at: month end + + + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Toy Story 5 + + total_amount: 400 + + + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: Backrooms + + total_amount: 200 + + + + EVENT'S ADDRESS + + TOKEN PROGRAM + USDC MINT + + + vault: the event's USDC + + 600 USDC + authority: the event PDA each user index caps a bettor's open bets (max 32) 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 5e8fb89a..6c07ed79 100644 --- a/building-financial-products-in-solana/figures/betting-alice-claim.svg +++ b/building-financial-products-in-solana/figures/betting-alice-claim.svg @@ -138,69 +138,70 @@ - + betting program initialize_config() - create_event() + initialize_event() add_outcome() - place_bet() - settle_event() - claim_winnings() - close_losing_bet() - cancel_event() - claim_refund() + open_betting() + place_bet() + settle_event() + claim_winnings() + close_losing_bet() + cancel_event() + claim_refund() - - "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: Toy Story 5 - - total_amount: 400 + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Toy Story 5 + + total_amount: 400 - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 1: Backrooms + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: Backrooms - - 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 + + 2. claim_winnings() · 447 USDC signed by the event PDA 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 8c931a1f..052b35a5 100644 --- a/building-financial-products-in-solana/figures/betting-maria-setup.svg +++ b/building-financial-products-in-solana/figures/betting-maria-setup.svg @@ -1,6 +1,6 @@ Maria opens the market - + Solana Devnet @@ -77,81 +77,83 @@ - + betting program initialize_config() - create_event() + initialize_event() add_outcome() ×2 - place_bet() - settle_event() - claim_winnings() - close_losing_bet() - cancel_event() - claim_refund() + open_betting() + place_bet() + settle_event() + claim_winnings() + close_losing_bet() + cancel_event() + claim_refund() - - "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 - 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: Toy Story 5 - - total_amount: 0 - NEW + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Toy Story 5 + + total_amount: 0 + NEW - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 1: Backrooms - - total_amount: 0 - NEW + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: Backrooms + + 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 call checks Maria's signature against config.admin; anyone else gets Unauthorized - add_outcome() locks once betting starts: BettingAlreadyStarted + open_betting() fixes the list: add_outcome() now fails with EventNotDraft no bet or user-index accounts exist yet 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 75d0d851..5064b940 100644 --- a/building-financial-products-in-solana/figures/betting-place-bets.svg +++ b/building-financial-products-in-solana/figures/betting-place-bets.svg @@ -158,72 +158,73 @@ - + betting program initialize_config() - create_event() + initialize_event() add_outcome() - place_bet() ×3 - settle_event() - claim_winnings() - close_losing_bet() - cancel_event() - claim_refund() + open_betting() + place_bet() ×3 + settle_event() + claim_winnings() + close_losing_bet() + cancel_event() + claim_refund() - - "CONFIG" - - - config - - - - "EVENT" + EVENT ID - - - event - - status: Open - total_pool: 600 - fee_bps: 200 - - - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 0: Toy Story 5 - - total_amount: 400 - - - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 1: Backrooms - - total_amount: 200 - - - - EVENT'S ADDRESS + - TOKEN PROGRAM + USDC MINT - - - vault: the event's USDC - - 600 USDC - authority: the event PDA - - - - + + "CONFIG" + + + config + + + + "EVENT" + EVENT ID + + + event + + status: Open + total_pool: 600 + fee_bps: 200 + + + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Toy Story 5 + + total_amount: 400 + + + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: Backrooms + + total_amount: 200 + + + + 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 2885ddce..03881e65 100644 --- a/building-financial-products-in-solana/figures/betting-settlement.svg +++ b/building-financial-products-in-solana/figures/betting-settlement.svg @@ -131,74 +131,75 @@ - + betting program initialize_config() - create_event() + initialize_event() add_outcome() - place_bet() - settle_event() - claim_winnings() - close_losing_bet() - cancel_event() - claim_refund() + open_betting() + place_bet() + settle_event() + claim_winnings() + close_losing_bet() + cancel_event() + claim_refund() - - "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: Toy Story 5 - - total_amount: 400 + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 0: Toy Story 5 + + total_amount: 400 - - "OUTCOME" + EVENT - ADDRESS + INDEX - - - outcome 1: Backrooms + + "OUTCOME" + EVENT + ADDRESS + INDEX + + + outcome 1: Backrooms - - 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(): Toy Story 5 wins diff --git a/building-financial-products-in-solana/figures/betting-state-machine.svg b/building-financial-products-in-solana/figures/betting-state-machine.svg index d53c208c..0bc57559 100644 --- a/building-financial-products-in-solana/figures/betting-state-machine.svg +++ b/building-financial-products-in-solana/figures/betting-state-machine.svg @@ -1,36 +1,47 @@ - + The event's four states + + + Draft + no bets yet + + add_outcome() - - Open - betting live - - place_bet() + + open_betting() + (admin) + + Open + bets until the close + + place_bet() - - Settled - winner set, fee paid - - settle_event() (admin) + + Settled + winner set, fee paid + + settle_event() (admin, + after the close) - - Cancelled - no fee taken - - cancel_event() (admin) + + Cancelled + no fee taken + + + cancel_event() (admin) - - bet account - closed - rent refunded - - claim_winnings() (winners) - - close_losing_bet() - (losers) - - claim_refund() (exact stake back) + + bet account + closed + rent refunded + + claim_winnings() (winners) + + close_losing_bet() + (losers) + + claim_refund() (exact stake back) - every path ends with the bet account deleted; deletion is the double-claim defense + every path ends with the bet account deleted; deletion is the double-claim defense diff --git a/financial-programming-concepts-in-solana/intro.md b/financial-programming-concepts-in-solana/intro.md index 7bc0694d..fb4cbfd9 100644 --- a/financial-programming-concepts-in-solana/intro.md +++ b/financial-programming-concepts-in-solana/intro.md @@ -16,7 +16,7 @@ Before writing any handler, draw this machine: name the states, name every legal Most programs are conceptually simple, with design rules that come straight from the state drawing: - **One instruction handler per transition.** `take_offer()` moves an offer from open to taken and does nothing else. Handlers that perform two transitions, or half of one, create bugs. -- **Make the state explicit.** A `status` enum on the account, or, often better, the account's own existence: the betting market's `Bet` account closes when the position ends, so "already claimed" means the account is gone. Using existence rather than a status is better since the state is recorded in one place rather than two. +- **Make the state explicit.** A `status` enum on the account, or, often better, the account's own existence: the betting market's `Bet` account closes when the position ends, so "already claimed" means the account is gone. Using existence rather than a status is better since the state is recorded in one place rather than two. A state inferred from other fields is worse than either. The betting market could treat an empty pool as "still being set up", but then anyone's first bet would end the setup, so it records a Draft status that only the admin can change. - **Check state, not just signers.** Betting on a settled event, canceling a filled order, and liquidating a healthy loan must fail because the *state* forbids them, with an error that names the rule. - **From every state, every participant must be able to get their money out.** Walking the states with this rule turns up the handler nobody wrote. An escrow with no cancel handler strands the maker's tokens the day nobody takes the offer. A betting market with no cancel-and-refund path strands every stake the day the event is called off. Walk each state and ask: how does each party's money leave from here? If any state has no answer, the program strands funds, and correct arithmetic will not fix that. - **An exit is not finished until nothing is left behind.** The vault is empty, the accounts are closed, and the rent is back with whoever paid it. diff --git a/financial-programming-concepts-in-solana/offchain-truth.md b/financial-programming-concepts-in-solana/offchain-truth.md index e4091689..a40d7c8f 100644 --- a/financial-programming-concepts-in-solana/offchain-truth.md +++ b/financial-programming-concepts-in-solana/offchain-truth.md @@ -33,7 +33,7 @@ At the far end are **price feed networks** like Pyth, which aggregate many indep The third question, **what happens when publication stops**, is the one designs forget. Feeds go stale during outages; committees deadlock; admins lose keys or interest. A program whose only paths require fresh truth is a program that can strand everyone's funds by hearing nothing. The staleness checks above handle the safety half (never act on old truth); the liveness half belongs to the state machine: an exit from every state that does not depend on the feed. The betting market can be canceled and refunded without an outcome ever being declared. A lending market whose oracle dies needs an escape hatch.[^escape-hatch] When you choose a trusted source, decide at the same time what the program does when it goes silent. -One more rule applies: **the fact's meaning must be fixed when the money commits, not when the fact arrives.** The betting market locks its outcome list the moment the first bet lands and snapshots its fee at event creation, so the question bettors staked on cannot be rewritten before it is answered. +One more rule applies: **the fact's meaning must be fixed when the money commits, not when the fact arrives.** The betting market fixes its outcome list when the admin opens it to bets, before any bet can land, and fixes its fee and its betting close time at event creation. The question bettors staked on cannot be rewritten before it is answered, and betting stops before the answer can be known. The price-manipulation section of the *Adversary* chapter covers the case these checks cannot catch: a signed fact that is fresh, confident, correctly verified, and *engineered*, a true report of a market the attacker just manipulated. diff --git a/financial-programming-concepts-in-solana/operating-financial-software.md b/financial-programming-concepts-in-solana/operating-financial-software.md index 2bd74140..b636caa1 100644 --- a/financial-programming-concepts-in-solana/operating-financial-software.md +++ b/financial-programming-concepts-in-solana/operating-financial-software.md @@ -52,7 +52,7 @@ The rule underneath is to match the level to how reversible your reaction is, be Every market in this book has an operator with real power: withdrawing fees, settling events, setting prices, changing weights. Admin power is normal, and on Solana upgrade authority is expected, because a program has to be fixable when a bug turns up. The failure is *unbounded* power. Write the admin key a specification with three parts: what it can do, what it can never do, and what happens when it goes rogue or goes silent. The rules below are that specification, as the book's programs implement it. - **Bound what configuration can express.** Validate every parameter at write time against a limit that makes abuse impossible by configuration. The vault strategy refuses a management fee above 1,000 basis points a year, because `collect_fees` mints shares to the manager and dilutes every depositor, so an uncapped fee would drain the vault. The lending program refuses a loan-to-value ratio above the liquidation threshold, so no loan it grants is liquidatable the moment it is taken. -- **Snapshot terms when money commits.** The betting market copies the config's fee onto each event when the event is created, so the fee an event pays is fixed the moment it opens. The vault strategy's fee has no setter at all. A parameter the admin can change applies to money that arrives after the change, never to money already inside. +- **Snapshot terms when money commits.** The betting market copies the config's fee onto each event when the event is created, along with the time betting closes, so the fee an event pays and the window bettors get are fixed before anyone can bet. The vault strategy's fee has no setter at all. A parameter the admin can change applies to money that arrives after the change, never to money already inside. - **Give the admin no path to principal.** No instruction handler in the book's programs, under any parameters, moves user deposits to the operator. The way to make that checkable is to keep the operator's money in its own place. The order book's fee vault holds only taker fees, and `withdraw_fees` moves only from that vault to the market authority. The lending reserve keeps its share in `accumulated_protocol_fees`, which the exchange rate subtracts before valuing a supplier's shares, so collecting it changes nothing a supplier is owed. Rebalancing moves funds between program-owned vaults, and settlement pays by formula. When that holds in your program, say it in those words rather than claiming "non-custodial": the rules are the deployed program, and the operator is not in them. - **Pause entrances, never exits.** A pause that refuses new deposits and new positions during an incident protects users; one that refuses withdrawals traps their money. The prop AMM's `paused` flag, set through `set_quote`, makes `swap` refuse and is read by no other handler. Whatever a pause stops, every participant must still be able to get their money out of every state, paused included. - **Plan for the admin going silent.** Let nothing a user does wait on the operator: a deposit, a withdrawal, or canceling your own offer runs without an admin signature. The order book's `withdraw_fees` returns success with nothing to move when the fee vault is empty, so a scheduled job can call it every hour without failing. And add a way out for the day the price feed dies. A lending market that can no longer value collateral needs a wind-down path that closes positions and lets deposits leave, rather than freezing every user in place until a price arrives. diff --git a/scripts/check-figure-geometry.mjs b/scripts/check-figure-geometry.mjs index cc8b52de..be9fcf5d 100644 --- a/scripts/check-figure-geometry.mjs +++ b/scripts/check-figure-geometry.mjs @@ -488,7 +488,7 @@ 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,601 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",