From 8204c4636a1563c0040f67a6e69e7263388d664b Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Sun, 4 Oct 2026 16:54:08 +0000 Subject: [PATCH 1/2] Drop checks-effects-interactions: re-entry cannot happen on Solana The book taught checks-effects-interactions as protection against a token hook or an upgraded callee re-entering a program mid-transfer. Solana's runtime rejects that call with ReentrancyNotAllowed, and a failed call undoes the whole transaction, so the order in which a handler writes its own state and calls other programs opens no attack. The Adversary chapter now states the runtime rule. The order book, fundraiser, managed fund, betting market, options and perpetual futures chapters describe what their handlers do without the label, and the glossary entry is gone. Follows quicknode/solana-program-examples#189, which removes the same claim from the programs' comments. Claude-Session: https://claude.ai/code/session_014pXG1pw56BE99p4A8qmrQk --- appendices/glossary.md | 4 ---- building-financial-products-in-solana/betting-market.md | 5 +---- building-financial-products-in-solana/fundraiser.md | 2 +- building-financial-products-in-solana/managed-fund.md | 2 +- building-financial-products-in-solana/options.md | 2 +- building-financial-products-in-solana/order-book.md | 4 ++-- .../perpetual-futures.md | 2 +- financial-programming-concepts-in-solana/the-adversary.md | 8 +++----- 8 files changed, 10 insertions(+), 19 deletions(-) diff --git a/appendices/glossary.md b/appendices/glossary.md index 9e97eb33..14cab63a 100644 --- a/appendices/glossary.md +++ b/appendices/glossary.md @@ -112,10 +112,6 @@ Rounding up to the next whole [minor unit](#minor-unit). The direction used for Arithmetic that reports failure instead of producing a wrong answer: Rust's `checked_add`, `checked_sub`, `checked_mul` return nothing when the result will not fit, so the handler can fail with a named error. The alternative is [wrapping](#wrapping). -### checks-effects-interactions - -The ordering rule for instruction handlers: validate everything, then update your own state, and only then transfer tokens or call other programs, so re-entry or hostile sequencing finds the program's state already updated. See the *Adversary* chapter. - ### close factor In a lending market, the fraction of a debt one liquidation may repay (typically 50%). See the *Lending and Borrowing* chapter. diff --git a/building-financial-products-in-solana/betting-market.md b/building-financial-products-in-solana/betting-market.md index 67903b00..5a76ce47 100644 --- a/building-financial-products-in-solana/betting-market.md +++ b/building-financial-products-in-solana/betting-market.md @@ -72,10 +72,7 @@ None of these payouts depended on when anyone bet. Had Alice bet first, when Toy 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. -Two orderings in this handler close attacks: - -- **The bet is removed from the user's index before the token transfer.** Effects before interactions, so no re-entry path can see stale state. -- **The bet account itself closes at the end of the claim**, rent refunded to the bettor, and **a closed account is the double-claim defense**: Alice claiming twice fails because the bet account the second claim needs is already gone (checked by `test_double_claim_fails`). +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`). There is no `claimed` boolean to forget to check. diff --git a/building-financial-products-in-solana/fundraiser.md b/building-financial-products-in-solana/fundraiser.md index ab9a32c8..ca1349d0 100644 --- a/building-financial-products-in-solana/fundraiser.md +++ b/building-financial-products-in-solana/fundraiser.md @@ -85,7 +85,7 @@ The test `test_stale_contribution_cannot_refund_from_next_raise` runs the attack Now the other ending: the deadline passes with only 3,600 USDC in the counter, from Alice, Bob, Carol, and Dave, who contributed 800. No handler marks the raise as failed; `refund()` simply starts accepting calls. Alice calls it. The handler checks the deadline has passed (`FundraiserNotEnded` otherwise) and the target was missed (`TargetMet` otherwise). It then reads her receipt's `amount`, subtracts it from `current_amount`, zeroes the receipt, transfers exactly that amount from the vault back to her token account with the PDA's signature, and closes the receipt to return her rent. -The order inside that handler is checks-effects-interactions: the receipt is zeroed and the counter decremented *before* the token transfer. A second refund fails because the receipt it would read has been closed. The escrow closed its offer accounts for the same reason. +A second refund fails because the receipt it would read has been closed. The escrow closed its offer accounts for the same reason. ![](figures/fundraiser-refunds.svg) diff --git a/building-financial-products-in-solana/managed-fund.md b/building-financial-products-in-solana/managed-fund.md index 10eb90da..37e49ee5 100644 --- a/building-financial-products-in-solana/managed-fund.md +++ b/building-financial-products-in-solana/managed-fund.md @@ -70,7 +70,7 @@ Traditional funds sell assets to pay the management fee. This program does what ## Withdrawing In Kind -Alice takes profit with `withdraw()`, burning her 900 shares. The program does not sell anything to pay her: it transfers to her **her proportional slice of every vault, in kind**. That is her share of the recorded holdings in the USDC vault, the TSLAx vault (about 1.5), and the NVDAx vault (about 2.8), each amount floored, each destination account verified to be hers and of the right mint (`InvalidRecipient`). The share count is reduced before any token moves (checks-effects-interactions, as in every chapter), and her `min_usdc_out` floor protects the USDC leg (`test_withdraw_rejects_slippage`). +Alice takes profit with `withdraw()`, burning her 900 shares. The program does not sell anything to pay her: it transfers to her **her proportional slice of every vault, in kind**. That is her share of the recorded holdings in the USDC vault, the TSLAx vault (about 1.5), and the NVDAx vault (about 2.8), each amount floored, each destination account verified to be hers and of the right mint (`InvalidRecipient`). The share count drops by her 900 shares, and her `min_usdc_out` floor protects the USDC leg (`test_withdraw_rejects_slippage`). She entered with $900, held through NVIDIA's rise, paid Maria 1% by dilution, and left with about $950 of assets. Bob still holds 450 shares, unrealized. Maria holds 13.5 shares, about $14, for managing the fund. Victor took no fee on any of it. What he gained is what his platform depends on: a track record of funds whose managers cannot take the assets. diff --git a/building-financial-products-in-solana/options.md b/building-financial-products-in-solana/options.md index c6a84d51..3822fe9d 100644 --- a/building-financial-products-in-solana/options.md +++ b/building-financial-products-in-solana/options.md @@ -93,7 +93,7 @@ The handler checks the terms before it touches a token: - **A zero premium** is refused the same way. It would make the option a gift rather than a sale. - **An expiry at or before now** is refused (`ExpiryInPast`). The holder may exercise while `now < expiry`, so such an option could never be exercised. -Then the effects come before the transfer, in this order: +Then, before the transfer, the handler makes these changes in order: 1. The `OptionContract` is written with status `Listed` and an empty holder. 2. The market's `underlying_owed` rises by 5 NVDAx. diff --git a/building-financial-products-in-solana/order-book.md b/building-financial-products-in-solana/order-book.md index 7e96182e..cb82b50e 100644 --- a/building-financial-products-in-solana/order-book.md +++ b/building-financial-products-in-solana/order-book.md @@ -102,7 +102,7 @@ Traditional exchanges also let a trader shrink a resting order in place and keep Unsettled balances are claims, not tokens. When Bob and Carol want their assets in hand, each calls `settle_funds()`, and the market PDA signs the transfers out of the vaults: Bob receives three NVDAx shares and his 250 USDC refund; Carol receives her 748.125 USDC. -One line in the handler belongs in everything you build: it snapshots the unsettled amounts and **zeroes the counters before making the transfers**. If a token transfer could ever call back into this program (token extensions can attach hooks to transfers), a caller re-entering mid-transfer would find balances already zeroed instead of still-credited, so double-withdrawal is impossible. Zeroing state before the transfer follows checks-effects-interactions, a rule the *Adversary* chapter explains fully. +The handler snapshots the unsettled amounts, **zeroes the counters**, and transfers the snapshot, so a second `settle_funds()` finds nothing to pay. If either transfer fails, the whole transaction is undone and the counters keep their values. ![](figures/order-book-settle.svg) @@ -160,7 +160,7 @@ The order book added six concepts on top of the escrow's: - **Every resting order is a funded commitment.** `place_order()` moves the tokens into a vault before the order rests, so no matched order can turn out to be backed by nothing. - **A data structure chosen for onchain cost.** The critbit tree inserts without shifting other entries, and walking it visits orders in price-time priority, on a chain where compute is metered. -- **An internal ledger that consolidates payouts.** Unsettled balances turn many small payouts into one claimed transfer, and zeroing them before the transfer (checks-effects-interactions) makes double-withdrawal impossible. +- **An internal ledger that consolidates payouts.** Unsettled balances turn many small payouts into one claimed transfer, and zeroing them as they are paid out means each balance is paid once. - **A fee whose rounding direction is a security decision.** Ceiling division on the fee closes a leak an attacker could exploit at scale through thousands of tiny fills. - **Vault separation plus stored-address checks.** Two vaults holding the same token are indistinguishable to the token program; only stored-address checks keep users' balances and the market's fees apart. - **A full book evicts its worst order.** Account space is finite, so a side that refused every order once full would let anyone with the capital to fill it keep every new order off it. Eviction replaces the order least likely to fill with a better one, refunding it as a cancel would. diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index b0f40cdf..9ceeff5b 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -212,6 +212,6 @@ This chapter added seven concepts: - **Funding.** A pool-wide index plus a per-position entry snapshot charges the crowded side a fee without visiting every position. Production venues keep the same bookkeeping and scale the rate with the imbalance. - **Mark-to-market share pricing.** LP shares price against AUM including traders' unrealized PnL, so entering and leaving the pool is fair at any instant. -This chapter used mints and token accounts, PDAs as vault authorities, shares priced against a pool, amounts read from state fields rather than vault balances, and checks-effects-interactions without explaining any of them, because earlier chapters already had. The same goes for a way out of every state for every participant, a signed external price checked before use, cranks run by profit-seekers, and integer arithmetic that rounds in the program's favor. In the *Where to Go From Here* chapter we will cover what this book left out, and how to take a program of your own from an idea to mainnet. The companion repository holds the code. +This chapter used mints and token accounts, PDAs as vault authorities, shares priced against a pool, and amounts read from state fields rather than vault balances without explaining any of them, because earlier chapters already had. The same goes for a way out of every state for every participant, a signed external price checked before use, cranks run by profit-seekers, and integer arithmetic that rounds in the program's favor. In the *Where to Go From Here* chapter we will cover what this book left out, and how to take a program of your own from an idea to mainnet. The companion repository holds the code. [^perps-volume]: [defillama.com](https://defillama.com) tracks spot and perpetual volumes side by side, so the two can be compared on one page. It moves with the market. diff --git a/financial-programming-concepts-in-solana/the-adversary.md b/financial-programming-concepts-in-solana/the-adversary.md index cb1119c1..0e61fc4b 100644 --- a/financial-programming-concepts-in-solana/the-adversary.md +++ b/financial-programming-concepts-in-solana/the-adversary.md @@ -85,15 +85,13 @@ Fix this by following the rounding rules in the *Arithmetic of Money* chapter ev A transaction can call your program, which calls another, which calls another. The flash loan in the *Core Building Blocks of Solana* chapter is built from programs calling programs. Each call is also a point where code you did not write runs: between your instructions in the same transaction, or inside a CPI your program makes. -Follow a rule called **checks-effects-interactions**: validate everything, then update your own state, and only then call other programs or move tokens. The order book's settlement zeroes a user's claimed balance *before* the transfer. If the token transfer could call back into the program (token extensions can attach hooks to transfers), the second call would find the balance already spent. Solana's runtime forbids a program from being re-entered through another program. Only a program calling itself directly is allowed, and its depth is capped. Keep the order anyway, because it also protects against changes you cannot see yet: a callee upgraded tomorrow, a hook added to a mint next year, an instruction placed after yours in the same transaction. - -Three more rules for these boundaries: +Code inside a CPI cannot call back into your program. Solana's runtime refuses to invoke a program that is already on the call stack, unless the program is calling itself directly: if your program calls the token program, and a transfer hook on the mint tries to call your program, the transaction fails with `ReentrancyNotAllowed`. A callee upgraded tomorrow or a hook added to a mint next year meets the same refusal. And if any call fails, the whole transaction is undone, including every change your handler made before it. So the order in which a handler updates its own state and calls other programs does not open an attack. Three things at these boundaries do need care: - **Validate what a CPI returns and what it was given.** Calling a program does not make the accounts it touched trustworthy. The managed fund computes each swap's minimum output from the oracle, because the router it calls is outside its control. - **Read again what a CPI changed.** A framework that copies an account into memory when the handler starts keeps that copy through the CPI. After a transfer out of a vault, the copy still shows the old balance, and a check against it passes on a number that is no longer true. The AMM re-reads both vaults after its transfers, before it re-checks K.[^reload-per-framework] - **Assume the instructions after yours are hostile.** Any invariant your program needs must hold at the end of *your* instruction, not at the end of the transaction, because the instructions after yours belong to someone else. -The *Arithmetic of Money* chapter's postcondition checks, such as re-checking K after the transfers land, follow the same rule: check your own state before anyone else's code runs. +The *Arithmetic of Money* chapter's postcondition checks, such as re-checking K after the transfers land, do this: they check the program's own state before anyone else's code runs. ## Every Role Holder Is Also a User @@ -113,7 +111,7 @@ Where the role is the one reporting a result, like the betting market's admin se - Build a defense against the inflation attack into any pool that issues shares: locked shares, virtual shares, a pool size kept in program state, or a large minimum first deposit. - A true price can still be a manipulated price: time-weight it, act on the confidence interval, cap how much can move per window, and never lend more against a token than it would cost to move its market. - A rounding error in the user's favor gets repeated until the pool is empty, so write a round-trip invariant test to catch it. -- Apply checks-effects-interactions at every boundary: your invariants must hold at the end of your own instruction, because the instructions after yours belong to someone else. +- Make every invariant hold at the end of your own instruction, because the instructions after yours belong to someone else. - Treat everyone with a role as a user too: for each role, ask what its handlers can change, and what that change is worth to someone who also trades, deposits, or bets in the same program. [^confused-deputy]: Norm Hardy named it in "The Confused Deputy (or why capabilities might have been invented)", ACM SIGOPS Operating Systems Review 22(4), October 1988, at [dl.acm.org/doi/10.1145/54289.871709](https://dl.acm.org/doi/10.1145/54289.871709). From e2c72b34cb9f1eb89d70a14618c66d74a5dea4aa Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Sun, 4 Oct 2026 17:00:11 +0000 Subject: [PATCH 2/2] Take checks-effects-interactions out of the settlement and claim figures The order book settlement figure said the ordering means "a re-entrant caller finds nothing left", and the betting claim figure labelled its first step "effects before interactions". Neither applies on Solana. The settlement caption now says what the handler does; the claim figure drops the label. Claude-Session: https://claude.ai/code/session_014pXG1pw56BE99p4A8qmrQk --- .../figures/betting-alice-claim.svg | 5 ++--- .../figures/order-book-settle.svg | 4 ++-- building-financial-products-in-solana/order-book.md | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) 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 dcc73f67..682ee914 100644 --- a/building-financial-products-in-solana/figures/betting-alice-claim.svg +++ b/building-financial-products-in-solana/figures/betting-alice-claim.svg @@ -206,9 +206,8 @@ signed by the event PDA rent refund - 1. index entry removed first - effects before interactions - 3. bet account closed + 1. index entry removed + 3. 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/order-book-settle.svg b/building-financial-products-in-solana/figures/order-book-settle.svg index c0a2e504..40a633cb 100644 --- a/building-financial-products-in-solana/figures/order-book-settle.svg +++ b/building-financial-products-in-solana/figures/order-book-settle.svg @@ -239,8 +239,8 @@ - each handler zeroes the counters before it makes the transfers: - checks-effects-interactions, so a re-entrant caller finds nothing left + the handler zeroes the counters and pays out what they held, + so a user who settles again is paid nothing 1 2 diff --git a/building-financial-products-in-solana/order-book.md b/building-financial-products-in-solana/order-book.md index cb82b50e..d188d329 100644 --- a/building-financial-products-in-solana/order-book.md +++ b/building-financial-products-in-solana/order-book.md @@ -102,7 +102,7 @@ Traditional exchanges also let a trader shrink a resting order in place and keep Unsettled balances are claims, not tokens. When Bob and Carol want their assets in hand, each calls `settle_funds()`, and the market PDA signs the transfers out of the vaults: Bob receives three NVDAx shares and his 250 USDC refund; Carol receives her 748.125 USDC. -The handler snapshots the unsettled amounts, **zeroes the counters**, and transfers the snapshot, so a second `settle_funds()` finds nothing to pay. If either transfer fails, the whole transaction is undone and the counters keep their values. +The handler snapshots the unsettled amounts, **zeroes the counters**, and transfers the snapshot, so a user who calls `settle_funds()` again is paid nothing. If either transfer fails, the whole transaction is undone and the counters keep their values. ![](figures/order-book-settle.svg)