From 490a7b7fbb91b8cb2ce9cd069fc8e7afdf9ffb23 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Sun, 4 Oct 2026 17:35:05 +0000 Subject: [PATCH 1/2] Betting Market: drop the user account the companion program removed quicknode/solana-program-examples#187 removed the per-wallet User index, its 32-position cap, TooManyBets, and slot recycling in close_losing_bet(). The chapter now says a wallet's open bets are found by filtering bet accounts on the bettor's address, citing test_no_cap_on_open_bets_per_wallet; Carol's close_losing_bet() only returns her rent; the Slot exhaustion failure mode goes. The four figures lose the user index boxes, the bet boxes move up into the gap, and Alice's claim renumbers its steps. Claude-Session: https://claude.ai/code/session_015fQ8KASdfsckQAyGYfezs8 --- .../betting-market.md | 9 ++-- .../figures/betting-account-map.svg | 37 +++-------------- .../figures/betting-alice-claim.svg | 41 +++++-------------- .../figures/betting-maria-setup.svg | 2 +- .../figures/betting-place-bets.svg | 39 +++--------------- .../figures/betting-settlement.svg | 30 +++----------- 6 files changed, 33 insertions(+), 125 deletions(-) diff --git a/building-financial-products-in-solana/betting-market.md b/building-financial-products-in-solana/betting-market.md index 6da16f73..785f036d 100644 --- a/building-financial-products-in-solana/betting-market.md +++ b/building-financial-products-in-solana/betting-market.md @@ -40,13 +40,13 @@ Outcomes can be added only while the event is a Draft, and only Maria can move i 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 Glowbugs 3. Bob agrees but has less to spend: $100 on Glowbugs 3. Carol believes in word of mouth and puts $200 on The Quiet Floor. -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 increases 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: only a first bet adds one to the outcome's `bet_count` and adds the bet to the bettor's list of open bets. +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 increases 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: only a first bet adds one to the outcome's `bet_count`. After all three bets, the vault holds 600 USDC: 400 staked on Glowbugs 3, 200 on The Quiet Floor. ![](figures/betting-place-bets.svg) -A bettor's first bet also created two accounts for them. Each **bet** is an account at a PDA (seeds: `"bet"`, the outcome, the bettor), holding the bettor, the outcome index, and the amount. One bet account per bettor per outcome: betting again on the same outcome tops up the existing account rather than creating a second one. And each bettor gets a **user** account, also at a PDA, indexing their open bet accounts, capped at 32 concurrent positions so the account never needs to grow. Removals use swap-remove, because the order of a list of bets means nothing and moving the last entry into the gap is the deletion that moves only one entry. +A bettor's first bet on an outcome also created an account for them. Each **bet** is an account at a PDA (seeds: `"bet"`, the outcome, the bettor), holding the bettor, the outcome index, and the amount. One bet account per bettor per outcome: betting again on the same outcome tops up the existing account rather than creating a second one. The bettor's address is the first field after the account's discriminator, so a wallet app lists someone's open bets by asking an RPC node for every bet account with their address at that offset (`getProgramAccounts` with a `memcmp` filter). The program keeps no list of a wallet's bets, so it sets no limit on how many a wallet holds open at once (checked by `test_no_cap_on_open_bets_per_wallet`). ## The Film Grosses: The Operator Settles the Event @@ -75,7 +75,7 @@ None of these payouts depended on when anyone bet. Had Alice bet first, when Glo 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 at most a few minor units of dust can remain in the vault, and dust belongs to the house, not to whichever claimant happened to arrive last. -The claim removes the bet from the user's index and 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`). +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`). There is no `claimed` boolean to forget to check. @@ -83,7 +83,7 @@ There is no `claimed` boolean to forget to check. ## Closing Out, and Getting Assets Out of Every State -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 path for losing bets 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`. +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. Both rules are checked by `test_close_losing_bet_only_after_settle_and_only_for_losers`. And what if the film festival burns down? An Open event can also end another way: `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. Draft, Open, Settled, Cancelled: four states, and in each one every bet account has a handler that pays it out or closes it. @@ -116,7 +116,6 @@ Every checked operation maps to `MathOverflow`. There are no rates and no curves - **Double claims** fail because the bet account is gone. There is no flag to desynchronize. - **Changing the terms after betting opens**: `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 settlement or cancellation** rejects with `EventNotOpen`, whether the event was settled or canceled. ## Testing Considerations 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 8aa5d496..c6f65f75 100644 --- a/building-financial-products-in-solana/figures/betting-account-map.svg +++ b/building-financial-products-in-solana/figures/betting-account-map.svg @@ -29,16 +29,7 @@ 700 USDC - - - "USER" + - BETTOR ADDRESS - - - Alice's user index - - bets: [Alice's bet] - + "BET" + @@ -51,6 +42,7 @@ outcome_index: 0 amount: 300 + @@ -71,16 +63,7 @@ 900 USDC - - - "USER" + - BETTOR ADDRESS - - - Bob's user index - - bets: [Bob's bet] - + "BET" + @@ -93,6 +76,7 @@ outcome_index: 0 amount: 100 + @@ -113,16 +97,7 @@ 800 USDC - - - "USER" + - BETTOR ADDRESS - - - Carol's user index - - bets: [Carol's bet] - + "BET" + @@ -135,6 +110,7 @@ outcome_index: 1 amount: 200 + @@ -228,5 +204,4 @@ 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 9d6e4abb..73da7e13 100644 --- a/building-financial-products-in-solana/figures/betting-alice-claim.svg +++ b/building-financial-products-in-solana/figures/betting-alice-claim.svg @@ -30,16 +30,7 @@ 1,147 USDC - - - "USER" + - BETTOR ADDRESS - - - Alice's user index - - bets: [ ] - + "BET" + @@ -54,6 +45,7 @@ + @@ -70,14 +62,7 @@ Bob's USDC - - - "USER" + - BETTOR ADDRESS - - - Bob's user index - + "BET" + @@ -87,6 +72,7 @@ Bob's bet + @@ -103,14 +89,7 @@ Carol's USDC - - - "USER" + - BETTOR ADDRESS - - - Carol's user index - + "BET" + @@ -120,6 +99,7 @@ Carol's bet + @@ -202,12 +182,11 @@ authority: the event PDA - 2. claim_winnings() · 447 USDC + 1. claim_winnings() · 447 USDC signed by the event PDA - - rent refund - 1. index entry removed - 3. bet account closed + + rent refund + 2. bet account closed 300 stake + floor(300 × 196 ÷ 400) = 300 + 147 = 447; the 149 left is Bob's exact claim closing the bet account prevents a double claim: no account, no second claim 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 a7a6444e..d86b6f16 100644 --- a/building-financial-products-in-solana/figures/betting-maria-setup.svg +++ b/building-financial-products-in-solana/figures/betting-maria-setup.svg @@ -155,5 +155,5 @@ every call checks Maria's signature against config.admin; anyone else gets Unauthorized open_betting() fixes the list: add_outcome() now fails with EventNotDraft - no bet or user-index accounts exist yet + no bet 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 9d036d5a..7d7a58aa 100644 --- a/building-financial-products-in-solana/figures/betting-place-bets.svg +++ b/building-financial-products-in-solana/figures/betting-place-bets.svg @@ -30,17 +30,7 @@ 700 USDC - - - "USER" + - BETTOR ADDRESS - - - Alice's user index - - bets: [Alice's bet] - NEW - + "BET" + @@ -54,6 +44,7 @@ amount: 300 NEW + @@ -73,17 +64,7 @@ 900 USDC - - - "USER" + - BETTOR ADDRESS - - - Bob's user index - - bets: [Bob's bet] - NEW - + "BET" + @@ -97,6 +78,7 @@ amount: 100 NEW + @@ -116,17 +98,7 @@ 800 USDC - - - "USER" + - BETTOR ADDRESS - - - Carol's user index - - bets: [Carol's bet] - NEW - + "BET" + @@ -140,6 +112,7 @@ amount: 200 NEW + diff --git a/building-financial-products-in-solana/figures/betting-settlement.svg b/building-financial-products-in-solana/figures/betting-settlement.svg index dcd6f769..ef12c153 100644 --- a/building-financial-products-in-solana/figures/betting-settlement.svg +++ b/building-financial-products-in-solana/figures/betting-settlement.svg @@ -27,14 +27,7 @@ Alice's USDC - - - "USER" + - BETTOR ADDRESS - - - Alice's user index - + "BET" + @@ -44,6 +37,7 @@ Alice's bet + @@ -60,14 +54,7 @@ Bob's USDC - - - "USER" + - BETTOR ADDRESS - - - Bob's user index - + "BET" + @@ -77,6 +64,7 @@ Bob's bet + @@ -93,14 +81,7 @@ Carol's USDC - - - "USER" + - BETTOR ADDRESS - - - Carol's user index - + "BET" + @@ -110,6 +91,7 @@ Carol's bet + From 27ad576f0c31823b9a4fecffb5af75390442f44c Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Sun, 4 Oct 2026 17:35:47 +0000 Subject: [PATCH 2/2] Betting Market: correct the parimutuel history from a fact-check Oller devised the mutual system in 1867 (his 1865 shop ran lottery-style pools); the tote is the British and Commonwealth name; the 2 June 1891 law banned bookmakers and left authorized pari-mutuel as the only legal horse bet; Churchill Downs had parimutuel machines by 1878. Polymarket and Kalshi do not pool stakes: they run order books, so the line says what they share (no bookmaker) and how they differ. Claude-Session: https://claude.ai/code/session_015fQ8KASdfsckQAyGYfezs8 --- building-financial-products-in-solana/betting-market.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/building-financial-products-in-solana/betting-market.md b/building-financial-products-in-solana/betting-market.md index 785f036d..0fc49a7e 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 the tote, was invented in 1867 by Joseph Oller, who took bets on horse races in Paris. France banned bookmakers in 1891 and made it the only legal way to bet on horses, and it spread from France to racetracks around the world. Prediction markets such as Polymarket and Kalshi use a similar model to this, though they trade outcome shares on an order book rather than pooling stakes. +- **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 also have no bookmaker, but they don't pool stakes: they match buyers and sellers of outcome shares on an order book. 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?