From 7c248f796bce4b9da80fdb2c67d8814285280793 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 19:35:17 +0000 Subject: [PATCH 1/7] Fundraiser, perps: answer Noah Prince's review (prose, first pass) Fundraiser: remove the per-backer cap section, give the counter-versus- balance section its real reason (the claim and refund must read the same number), and explain why the fundraiser outlives its receipts. Perps: LPs hold the opposite of the traders' net position and earn only net funding; rewrite the production-funding sentence plainly. Glossary: add initial margin. Figures and the margin and price-band sections follow. Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- appendices/glossary.md | 10 +- .../figures/fundraiser-cap.svg | 50 ---------- .../fundraiser.md | 92 +++++++++++-------- .../perpetual-futures.md | 10 +- 4 files changed, 64 insertions(+), 98 deletions(-) delete mode 100644 building-financial-products-in-solana/figures/fundraiser-cap.svg diff --git a/appendices/glossary.md b/appendices/glossary.md index 997be84a..715ccb65 100644 --- a/appendices/glossary.md +++ b/appendices/glossary.md @@ -300,6 +300,10 @@ Advancing an [index](#index) for the time elapsed since it was last updated, whi The empty-pool exploit: a dust-sized first deposit, then a donation straight into the vault, inflates the value behind each early share so later depositors' shares round to nothing. See the *Adversary* chapter. +### initial margin + +The minimum collateral a leveraged position needs to open, usually stated in basis points of its notional. It sets the maximum leverage: a 10% initial margin is 10x. It sits above the [maintenance margin](#maintenance-margin), so a new position has room to move before it can be liquidated. See the *Perpetual Futures* chapter. + ### informed flow Trades from someone who knows where the price is about to move, such as an arbitrage bot that has seen the price change on another venue before a market maker's quote has caught up. "Informed" says nothing about how experienced the trader is. Its opposite is uninformed flow: people who trade because they want the asset. The *Prop AMM* chapter covers it with [adverse selection](#adverse-selection) and [order segmentation](#order-segmentation). @@ -350,7 +354,7 @@ A pooled borrow-lend venue: suppliers earn interest, borrowers pay it against co ### leverage -Controlling more exposure than the collateral posted: a 5,000 USDC position over 1,000 USDC of collateral is 5x leverage. Leverage multiplies both the gain and the loss of every price move. +Controlling more exposure than the collateral posted: a 5,000 USDC position over 1,000 USDC of collateral is 5x leverage. Leverage multiplies both the gain and the loss of every price move. The maximum leverage a venue allows is one over its [initial margin](#initial-margin). ### limit order @@ -386,7 +390,7 @@ The receipt token minted to a liquidity provider, representing a proportional cl ### maintenance margin -The minimum equity a leveraged position must keep, usually stated in basis points of its notional. Below it, the position may be liquidated by anyone. +The minimum equity a leveraged position must keep, usually stated in basis points of its notional. Below it, the position may be liquidated by anyone. It is lower than the [initial margin](#initial-margin) needed to open the position. See the *Perpetual Futures* chapter. ### major unit @@ -686,7 +690,7 @@ A signed bundle of instructions that executes atomically: all of it lands or non ### TWAP -A time-weighted average price: the average of a market's price over a window, used instead of the instantaneous price so a manipulator must hold a distortion for the whole window rather than for one slot. See the *Adversary* chapter. +A time-weighted average price: the average of a market's price over a window, used instead of the instantaneous price so a manipulator must hold a distortion for the whole window rather than for one slot. See the *Adversary* chapter, and the *Perpetual Futures* chapter for a program that keeps its own average of an oracle's price and refuses trades at prices too far from it. ### underlying diff --git a/building-financial-products-in-solana/figures/fundraiser-cap.svg b/building-financial-products-in-solana/figures/fundraiser-cap.svg deleted file mode 100644 index 93b7674f..00000000 --- a/building-financial-products-in-solana/figures/fundraiser-cap.svg +++ /dev/null @@ -1,50 +0,0 @@ - - Nobody carries more than a tenth - - - - - - - - - - - - - - - - - - Alice 900 - Bob 900 - Carol 450 + 450 - Dave 800 - Erin 900 - Frank 700 - Grace 900 - Henry 750 - Iris 900 - Jack 550 - Kira 800 - - - target: 9,000 USDC - - - - - the cap: 10% of target, - so 900 USDC, and no slice is taller - - the cap forces a crowd - no backer may exceed 900, and the cap is cumulative - across their contributions, so a successful raise needs - at least ten distinct backers. This one took eleven, - because most of them gave less than the cap. - over the cap in one call: ContributionTooBig - over the cap across calls: MaximumContributionsReached - under one major unit: ContributionTooSmall - - diff --git a/building-financial-products-in-solana/fundraiser.md b/building-financial-products-in-solana/fundraiser.md index bb2ade18..5e78609c 100644 --- a/building-financial-products-in-solana/fundraiser.md +++ b/building-financial-products-in-solana/fundraiser.md @@ -26,97 +26,111 @@ The **fundraiser** account lives at a PDA with seeds `"fundraiser"` and the make - `amount_to_raise`: the target, 9,000 USDC in minor units. `initialize_fundraiser()` rejects a target below three major units with `InvalidAmount`, so a dust-sized raise cannot exist. - `current_amount`: the running total the program itself has counted, which is deliberately not the same number as the vault's balance. - `time_started` and `duration`: the clock's Unix timestamp at creation, and the window length in days. The program does not store a deadline; it computes one from these two fields. +- `claimed`: set when Maria claims a successful raise, after which the program accepts no more contributions and no second claim. +- `open_contributor_accounts`: how many backers' receipts, described below, are still open. The fundraiser account cannot close until this is zero. The **vault** is an associated token account whose authority is the fundraiser PDA, the arrangement the escrow used: tokens sit in an account only the program can sign for. The handler stamps `time_started` from the cluster clock and stores the duration. Both later handlers reconstruct the deadline the same way: elapsed days are `(now − time_started) / 86,400`, integer division, floored, using the constant `SECONDS_TO_DAYS`. `contribute()` requires the floored day count to be *less than* the duration; `refund()` requires it to be *at least* the duration, so every moment falls in exactly one of the two windows. The switch happens exactly 604,800 seconds (7 × 86,400) after `time_started`: from that second on, `contribute()` fails and `refund()` works. -## Nobody May Carry More Than a Tenth of the Raise +## Every Backer Gets a Receipt Eleven backers fund this raise, each starting with the standard wallet of one SOL and 1,000 USDC, and each with their own reason. Alice runs three cafes and wants Maria's machine on her counters. Bob distributes restaurant equipment and wants a new product line to carry. Carol is an espresso enthusiast who wants one at home badly enough to help it exist. None of them will trust Maria, or each other, with a refund promise. They will trust a program that gives Maria no way to redirect the vault to herself, and whose deployed code is verified to match its published source. -Maria wants the raise to show that many people want the machine, so one large backer should not be able to fund it alone. The program caps each backer at a tenth of the target, so **a successful raise needs at least ten contributors**. A single backer who wanted to fill the raise would need ten wallets, and the fundraiser would record ten receipts.[^proof-of-personhood] - -Alice tries to contribute her entire 1,000 USDC. The program refuses with `ContributionTooBig`. Her limit, computed by `calculate_max_contribution()`, is 10% of the target: 900 USDC. The constants behind it, `MAX_CONTRIBUTION_PERCENTAGE` over `PERCENTAGE_SCALER`, read 10/100, and the multiply runs in u128 before one floor division, multiply before divide, as in every other chapter. - -She contributes 900 USDC instead, and every backer makes the same call: `contribute()` moves the tokens from Alice's own token account into the vault, and adds 900 to `current_amount`. The program can deduct from Alice's token account because she signed the transaction. +Alice contributes her whole 1,000 USDC. `contribute()` moves the tokens from Alice's own token account into the vault and adds 1,000 to `current_amount`. The program can deduct from Alice's token account because she signed the transaction. ![](figures/fundraiser-contribute.svg) -The transaction also created an account for her. Each backer gets a **contributor** account at a PDA with seeds `"contributor"`, the fundraiser, and their own address, holding a single number: `amount`, their cumulative contribution. It is a receipt the program writes for itself. A backer's first `contribute()` creates it, every later one adds to it, and it is closed when the backer is refunded or, after a successful raise, when they call `close_contributor()`. - -The cap applies to each backer's total across all their calls. Carol contributes 450 USDC, then comes back the next day with 450 more, and her contributor account's running total reaches the 900 cap. A third contribution would fail with `MaximumContributionsReached`. And at the bottom end, anything under one major unit rejects with `ContributionTooSmall`, so nobody spams the raise with dust. +The transaction also created an account for her. Each backer gets a **contributor** account at a PDA with seeds `"contributor"`, the fundraiser, and their own address, holding a single number: `amount`, their cumulative contribution. It is a receipt the program writes for itself. A backer's first `contribute()` creates it and adds one to the fundraiser's `open_contributor_accounts`; every later contribution adds to the same receipt. Carol contributes 500 USDC, then comes back the next day with 500 more, and her receipt's `amount` reaches 1,000 while the fundraiser still counts one receipt for her. Anything under one major unit fails with `ContributionTooSmall`, so nobody fills the raise with dust. -So the contributions arrive like this: Alice 900, Bob 900, Carol 450 and 450, and eight more backers between 550 and 900 apiece. Ten backers is the minimum; this raise took eleven because most of them gave less than the cap. Total: exactly 9,000 USDC. +So the contributions arrive like this: Alice 1,000, Bob 800, Carol 500 and 500, and eight more backers giving 6,200 between them. Total: exactly 9,000 USDC, from eleven receipts. -![](figures/fundraiser-cap.svg) +The program sets no limit on what one backer may give, and does not stop Maria contributing to her own raise. A per-backer limit would count wallets, and a wallet costs nothing to create, so it would not stop one person funding most of a raise. What the program guarantees is narrower: Maria receives the vault only when the counter shows the full 9,000 USDC, so she never receives the partial raise that cannot buy a mold. Whether she then builds the machine is outside the program, as it is on Kickstarter. ## The Claim Reads the Program's Counter, Not the Vault Balance -The claim handler compares the target against `current_amount`, the program's own count of accepted contributions, and **never against the vault's token balance**. Anyone can transfer tokens to any token account on Solana, no permission required. If the check read the vault balance, Maria could top her own vault up with 5,400 USDC on day six, "reach" the target, and take 3,600 USDC of contributions the target was meant to protect. Because the check reads the counter, that transfer changes nothing: the test `test_check_contributions_ignores_direct_vault_donations` mints the full target straight into the vault and asserts that the claim still fails. +The claim handler compares the target against `current_amount`, the program's own count of accepted contributions, and **never against the vault's token balance**. Anyone can transfer tokens to any token account on Solana, no permission required, and a transfer straight into the vault moves the balance without moving the counter. -The same applies to any check that releases tokens from the vault: **it should read a number the program itself wrote**, because anyone can pass a check on a token balance by transferring tokens in. +`refund()` decides that a raise failed by reading `current_amount`, so the claim has to read the same number. If the claim read the balance instead, a transfer into the vault could make both handlers accept at once. Suppose 3,600 USDC had come in through `contribute()` and someone transferred 5,400 more straight into the vault. The balance would read 9,000, so the claim would pay all of it to Maria. The counter would still read 3,600, so `refund()` would promise each backer their contribution back, out of a vault that was now empty. -## The Target Is Met: The Maker Claims, and Everything Closes +Reading the counter keeps the two endings exclusive: the claim needs the counter at or above the target and a refund needs it below. The test `test_check_contributions_ignores_direct_vault_donations` transfers the full target straight into the vault and asserts that the claim still fails with `TargetNotMet`. Apply the same check to any handler that releases tokens from a vault: compare a number the program itself wrote, never a token balance anyone can raise by transferring tokens in. -`check_contributions()` is the maker's claim. Maria signs, the PDA seeds re-derive her fundraiser, and `current_amount` has reached the target, so the handler does not fail with `TargetNotMet`. Three things then happen in one transaction: the vault's entire balance transfers to Maria's token account (signed by the fundraiser PDA), the vault closes, and the fundraiser account closes too, returning both rent deposits to her. The program sets no "finished" flag. A second claim fails because the fundraiser account it would read no longer exists. The test `test_check_contributions_success_pays_maker_and_closes_vault` asserts all three effects. +## The Target Is Met: The Maker Claims + +`check_contributions()` is the maker's claim. Maria signs, the PDA seeds re-derive her fundraiser, and `current_amount` has reached the target, so the handler does not fail with `TargetNotMet`. It transfers the vault's entire balance to Maria's token account, signed by the fundraiser PDA, and sets `claimed`. A second claim fails with `FundraiserClaimed`, and so does a contribution sent after the claim, even with days left before the deadline. The test `test_check_contributions_success_pays_maker_and_marks_claimed` asserts the payment and the flag. ![](figures/fundraiser-success.svg) -The eleven contributor receipts are still open after the claim. `check_contributions()` does not close them, and `refund()`, which does, only runs if the raise fails. Without another handler, each backer's receipt rent (about a thousandth of a SOL) would be stuck in an account nothing could close, so the program has one more: `close_contributor()`. A backer signs, passes the address their receipt was written for, and the handler makes one check: the account at that address is no longer owned by the program. A live fundraiser is program-owned, and a closed one belongs to the system program again, so while the raise is open the call fails with `FundraiserStillOpen` and the receipt stays a live claim on the vault. Once the fundraiser is gone the receipt closes and the rent comes back, which `test_close_contributor_after_successful_claim_returns_rent` asserts. The handler pays out no tokens and checks nothing else. The general rule: *for every account your program creates, name the handler that closes it.* +## The Fundraiser Outlives Every Receipt + +The fundraiser account and the empty vault stay open after the claim, because the eleven receipts do. Each receipt lives at a PDA derived from the fundraiser's address, and the fundraiser's address is derived from `"fundraiser"` and Maria's address alone. If the claim closed the fundraiser, Maria could call `initialize_fundraiser()` again and get a new fundraiser at the same address, and every receipt still open would belong to it. + +Suppose that second raise fails. Alice's receipt from the first raise still reads 1,000 USDC, so `refund()` would pay her 1,000 USDC from the second raise's vault: tokens that the second raise's backers contributed, for a contribution Maria had already been paid. The program prevents this by keeping the fundraiser until `open_contributor_accounts` reaches zero, so no receipt can outlive the raise it was written for. + +`close_contributor()` closes one receipt after a claim and returns its rent, about a thousandth of a SOL, to the backer it belongs to. It makes one check, that the fundraiser is `claimed`, else `FundraiserNotClaimed`: before the claim a receipt is still a live claim on the vault, and only `refund()` may close it. The backer does not have to sign, because the rent can only go to them, so Maria does not have to wait for eleven backers to come back. She sends a `close_contributor()` for each receipt, and each call subtracts one from `open_contributor_accounts`. The tests `test_close_contributor_after_claim_returns_rent` and `test_anyone_can_close_contributor_accounts_after_claim` check that the rent reaches the backer either way. + +![](figures/fundraiser-close-receipts.svg) + +With the counter at zero, Maria calls `close_fundraiser()`. It sends her any tokens transferred into the vault without going through `contribute()`, then closes the vault and the fundraiser and returns both rent deposits to her. While any receipt is open it fails with `ContributorAccountsOpen`, and while the fundraiser account exists a second `initialize_fundraiser()` fails because the account is already there. + +![](figures/fundraiser-close.svg) + +The test `test_stale_contributor_account_cannot_refund_from_next_raise` runs the attack in full. A raise succeeds, its receipts and the fundraiser are closed, and Maria starts a second raise at the same address, which then fails. A first-raise backer's refund from the second raise fails, because their receipt no longer exists, and every second-raise backer is refunded exactly what they put in. `test_reinitialize_with_open_contributor_accounts_fails` checks that the second raise cannot start while a first-raise receipt is open. ## The Target Is Missed: Everyone Leaves With Exactly What They Brought -Now the other ending: the deadline passes with only 3,600 USDC in the counter. 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. +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. ![](figures/fundraiser-refunds.svg) -Each backer calls `refund()` for themselves, whenever they like; refunds have no deadline, and every contributor gets back exactly what they put in. +Refunds have no deadline. A backer usually sends their own, but `refund()` does not need their signature: the tokens can only go to the backer's token account and the rent to the backer, whoever sends the transaction. `test_anyone_can_refund_a_contributor` checks that a refund Maria sends still pays the backer. -The fundraiser account and the vault are still open. The fundraiser PDA is derived from `"fundraiser"` and Maria's address alone, so Maria cannot open a second raise while the first one still exists. Once every contribution has been refunded, she calls `close_fundraiser()`. It checks the deadline has passed, the target was missed, and the counter is back to zero (`RefundsOutstanding` otherwise, so she cannot close the vault while any backer is still owed). It then sends her any tokens that were transferred into the vault without going through `contribute()`, and closes both the vault and the fundraiser, returning her rent. Nothing of the failed raise remains onchain, and Maria can try again next quarter with a smaller target. Every account this program creates is closed on both paths: the receipts close through `refund()` here and through `close_contributor()` after a win. +The fundraiser account and the vault are still open. The fundraiser PDA is derived from `"fundraiser"` and Maria's address alone, so Maria cannot open a second raise while the first one still exists. Once every contribution has been refunded, she calls `close_fundraiser()`. On an unclaimed fundraiser it checks the deadline has passed, the target was missed, and the counter is back to zero (`RefundsOutstanding` otherwise, so she cannot close the vault while any backer is still owed). Dave has not come back for his 800 USDC, so Maria sends his refund herself, and then the fundraiser closes. It sends her any tokens that were transferred into the vault without going through `contribute()`, and closes both the vault and the fundraiser, returning her rent. Nothing of the failed raise remains onchain, and Maria can try again next quarter with a smaller target. Every account this program creates is closed on both paths: the receipts close through `refund()` here and through `close_contributor()` after a claim. -## The Math: Three Constants and a Floor +## The Math: Two Minimums and a Floor -The program's financial arithmetic is four expressions: +The program's financial arithmetic is three expressions: -- **The cap**: `amount_to_raise × 10 / 100`, in u128, floored. - **The minimum target**: `3 × 10^decimals` (`MIN_AMOUNT_TO_RAISE`). - **The minimum contribution**: `10^decimals`. - **The deadline**: `(now − time_started) / 86,400`, floored. -Every addition and subtraction on `current_amount` and the receipts is checked and maps overflow to `MathOverflow`. No rates, no pro-rata, no prices: refunds pay recorded amounts back verbatim, so, like the escrow, this program never has to *compute* what people are owed. +Every addition and subtraction on `current_amount`, the receipts and `open_contributor_accounts` is checked and maps overflow to `MathOverflow`. No rates, no pro-rata, no prices: refunds pay recorded amounts back verbatim, so, like the escrow, this program never has to *compute* what people are owed. -## The Invariants: The Counter Balances Like a Ledger +## The Invariants: The Counters Balance Like a Ledger -- `current_amount` equals the sum of all live contributor receipts, at every moment: contributions add to both together, refunds subtract from both together. -- Tokens that entered through `contribute()` equal `current_amount` plus tokens already refunded; the vault can only exceed that by direct donations, which no check ever reads. -- No contributor's receipt ever exceeds 10% of the target. +- Until the claim, `current_amount` equals the sum of all live receipts: contributions add to both together, and refunds subtract from both together. +- `open_contributor_accounts` equals the number of receipts that exist for the fundraiser. The companion repository's Kani harness `proof_open_contributor_accounts_counts_open_accounts` model-checks this over every order of contributions, refunds and closes up to its bound. +- The fundraiser account exists for as long as any receipt derived from its address does, so a receipt can only ever be read by the raise it was written for. +- Tokens that entered through `contribute()` equal `current_amount` plus tokens already refunded, until the claim pays them all out; the vault can only exceed that by direct transfers, which no check ever reads. - The claim requires `current_amount` to be at least the target and the refund requires it to be below, so a fundraiser can only end one way. -- Tokens leave the vault only through the claim or a refund, both signed by the fundraiser PDA, the claim requiring the maker's signature and the counter at the target, a refund requiring the contributor's signature, a passed deadline, and their own receipt. +- Tokens leave the vault through the claim, a refund, or `close_fundraiser()`, all signed by the fundraiser PDA. The claim pays Maria once the counter reaches the target. A refund pays one backer their own receipt's amount after a missed deadline. The close pays Maria only tokens that arrived outside `contribute()`. ## The Failure Modes: One Check per Attack - **Claiming early or after a miss** fails on the counter: `TargetNotMet`. +- **Claiming twice, or contributing after the claim,** fails with `FundraiserClaimed` (checked by `test_second_claim_fails` and `test_contribute_after_claim_fails`). - **Refunding early or after a win** fails on the deadline or the counter: `FundraiserNotEnded`, `TargetMet` (checked by `test_refund_before_deadline_fails` and `test_refund_when_target_met_fails`). - **Contributing after the deadline** fails with `FundraiserEnded` (checked by `test_contribute_after_deadline_fails`). -- **Capture by one large backer** fails on the cumulative cap: `ContributionTooBig` per call, `MaximumContributionsReached` across calls (checked by `test_contribute_above_cap_fails` and `test_cumulative_contributions_above_cap_fail`). -- **Inflating the vault directly** moves tokens but never moves the counter, so the claim still fails with `TargetNotMet` (checked by `test_check_contributions_ignores_direct_vault_donations`). +- **Transferring tokens straight into the vault** moves the balance but never the counter, so the claim still fails with `TargetNotMet` (checked by `test_check_contributions_ignores_direct_vault_donations`). +- **Reusing a receipt in the next raise at the same address** fails because the fundraiser cannot close while a receipt is open: `ContributorAccountsOpen` (checked by `test_stale_contributor_account_cannot_refund_from_next_raise` and `test_close_fundraiser_with_open_contributor_accounts_fails`). - **Double refunds** fail because the receipt account is gone. - **Dust contributions** fail on the one-major-unit minimum: `ContributionTooSmall`. -- **Closing the fundraiser early or with contributions inside** fails with `FundraiserNotEnded` before the deadline, `TargetMet` after a win, and `RefundsOutstanding` while any contribution remains unrefunded. -- **Closing a receipt while its raise is live** fails because the fundraiser account still belongs to the program: `FundraiserStillOpen` (checked by `test_close_contributor_while_fundraiser_open_fails`). +- **Closing the fundraiser early or with contributions inside** fails with `FundraiserNotEnded` before the deadline, `TargetMet` on a raise that met its target but is unclaimed, and `RefundsOutstanding` while any contribution remains unrefunded. +- **Closing a receipt before the claim** fails because it is still a claim on the vault: `FundraiserNotClaimed` (checked by `test_close_contributor_before_claim_fails`). ## Testing Considerations -The suite warps the clock with a helper that advances whole days, and asserts balances after every move. Three tests follow a whole lifecycle: +The suite warps the clock with a helper that advances whole days, and asserts balances and the specific error after every move. Four tests follow a whole lifecycle: -- `test_check_contributions_success_pays_maker_and_closes_vault` funds the raise with ten contributors at the cap and claims it. +- `test_check_contributions_success_pays_maker_and_marks_claimed` funds the raise with three unequal contributions and claims it. +- `test_close_fundraiser_after_claim_allows_a_new_raise` closes every receipt and the fundraiser after a claim, and checks the same wallet can raise again. - `test_refund_after_deadline_target_not_met_succeeds` follows the losing timeline to a clean refund. - `test_close_fundraiser_after_failed_raise_allows_a_new_raise` closes the failed fundraiser and checks the same wallet can raise again. -The rest each call a handler in a state where it must refuse, and assert the error, such as `test_refund_before_deadline_fails`. Every vault program needs a donation test: mint tokens straight into your vault outside your own handlers, then assert that nothing unlocks (`test_check_contributions_ignores_direct_vault_donations` on the claim; `test_close_fundraiser_sweeps_direct_donations_to_maker` checks that `close_fundraiser()` pays such tokens to the maker). +`test_stale_contributor_account_cannot_refund_from_next_raise` runs two raises at the same address, one after the other. The rest each call a handler in a state where it must refuse, and assert the error, such as `test_refund_before_deadline_fails`. Every vault program needs a donation test: mint tokens straight into your vault outside your own handlers, then assert that nothing unlocks (`test_check_contributions_ignores_direct_vault_donations` on the claim; `test_close_fundraiser_sweeps_direct_donations_to_maker` checks that `close_fundraiser()` pays such tokens to the maker). ## What You Learnt from Building the Fundraiser @@ -125,13 +139,11 @@ Kickstarter's all-or-nothing promise is a company policy: the platform holds the The fundraiser added five concepts on top of the escrow's: - **A deadline that decides which handlers work.** One floored day-count expression, compared both ways, so contributions and refunds can never both be legal at once. -- **Checks read the program's counter, not the vault balance.** A check that releases tokens from a vault must read a number the program itself wrote, never a token balance anyone can raise by transferring tokens in. +- **Checks read the program's counter, not the vault balance.** Make every handler that releases tokens read the same number the program itself wrote, so two handlers can never disagree about which ending happened. - **A receipt account per participant**, at a PDA, created on first use, added to on later contributions, and closed on refund, so a second refund finds nothing to read. -- **Cumulative caps.** Per-participant limits checked against running totals, so a raise cannot succeed without many participants. -- **Every account has a handler that closes it.** A successful raise closes the vault and the fundraiser but cannot reach the receipts, so a handler exists whose only job is to close one once the fundraiser is gone. +- **An account outlives every account derived from its address.** Count the derived accounts, and refuse to close the parent until the count is zero, so a new account at the same address never inherits the old one's receipts. +- **Every account has a handler that closes it.** A successful raise leaves the receipts open, so a handler exists to close each one, and anyone may send it because the rent can only go to its backer. In the *Betting Market* chapter we will build a pool that many people pay into, where who gets paid is decided by something the program cannot see: which outcome happened in the real world. Someone has to tell the program the answer, and you will see how the program limits what that person can do with the pooled stakes while trusting them for the result. [^any-mint]: Any mint works, Token Extensions mints included, because every account is declared through the token interface. - -[^proof-of-personhood]: The cap counts wallets, and a wallet costs nothing to create, so ten receipts show ten wallets rather than ten people. A raise that needs distinct people has to bring that proof from outside the program. Proof-of-personhood services exist to supply it: a person proves once, to the service, that they are a unique human, and a program can then require that proof before it accepts a contribution. Two such services are World ID ([world.org](https://world.org)) and VeryAI ([very.org](https://very.org)). How each proof reaches a Solana program differs by service, and checking one is outside this book. diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index fd17b6aa..703e55a8 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -6,7 +6,7 @@ The program lives at `finance/perpetual-futures` in the companion repository. It ## The Pool Is the Other Side of the Trade -In the *Order Book Exchange* chapter, every long met a short: Carol's ask crossed Bob's bid, and the program only recorded the result. A perp that trades against a pool has no orders to match. **Liquidity providers fund one pool, and the pool is the counterparty to every position.** When a trader wins, the pool pays; when a trader loses, the pool keeps the loss. So an LP's return is the losing traders' losses plus the funding the pool collects, minus whatever the winning traders take. The open and close fees go to the pool's creator, not to the LPs. +In the *Order Book Exchange* chapter, every long met a short: Carol's ask crossed Bob's bid, and the program only recorded the result. A perp that trades against a pool has no orders to match. **Liquidity providers fund one pool, and the pool is the counterparty to every position.** When a trader wins, the pool pays; when a trader loses, the pool keeps the loss. So the LPs hold the opposite of the traders' net position: short NVDAx while more traders are long, long while more are short. An LP's return is the losing traders' losses minus the winning traders' profits, plus the net funding described below. The open and close fees go to the pool's creator, not to the LPs. That design decision creates the problem the program is organized around: the program must be able to check that the pool can pay every winner, at every moment, **without ever iterating over the open positions**. A program that must touch every position to answer "am I solvent?" stops fitting in a transaction once it has enough open positions. That requirement explains almost every field in the account model. @@ -33,7 +33,7 @@ The pool is the mint authority of the **LP share mint** (seeds `"lp_mint"`) and ## Liquidity Providers Fund the Pool and Get Shares Priced at Mark -Carol is a liquidity provider. Her income is every losing trader's loss and every funding payment; her risk is every winning trader's profit. She calls `add_liquidity()` with her whole 1,000 USDC. She is the first depositor, so her shares are her deposit minus `MINIMUM_LIQUIDITY`: a withheld 1,000 minor units, a tenth of a cent, that no one will ever own. That is the same first-depositor defense the *Constant-Product AMM* chapter built. Both `add_liquidity()` and `remove_liquidity()` count the withheld shares in the supply they divide by, so their slice of the pool stays locked for good, and the failure modes below show the attack those withheld shares stop. The *Managed Fund* chapter ignores donations instead. Later deposits mint shares pro rata against **assets under management marked to market**, rather than against the vault balance or `liquidity` alone: `liquidity` minus the traders' current unrealized profit (or plus their unrealized loss). If LP shares were priced while ignoring open positions, a depositor could buy in cheap the moment traders were winning and dilute Carol at her worst moment. Marking AUM makes entering and leaving the pool fair at any instant, and both `add_liquidity()` and `remove_liquidity()` take a minimum-out argument so a price move between signing and execution fails with `SlippageExceeded` instead of filling badly. Eleven more providers follow Carol in over the morning, and the pool opens for trading with 12,000 USDC of liquidity. +Carol is a liquidity provider. She gains what losing traders lose and pays what winning traders win, so she holds whichever side of NVDAx the traders are net against. Funding adds to her return only by the difference between what the heavier side pays the pool and what the pool pays the lighter side. She calls `add_liquidity()` with her whole 1,000 USDC. She is the first depositor, so her shares are her deposit minus `MINIMUM_LIQUIDITY`: a withheld 1,000 minor units, a tenth of a cent, that no one will ever own. That is the same first-depositor defense the *Constant-Product AMM* chapter built. Both `add_liquidity()` and `remove_liquidity()` count the withheld shares in the supply they divide by, so their slice of the pool stays locked for good, and the failure modes below show the attack those withheld shares stop. The *Managed Fund* chapter ignores donations instead. Later deposits mint shares pro rata against **assets under management marked to market**, rather than against the vault balance or `liquidity` alone: `liquidity` minus the traders' current unrealized profit (or plus their unrealized loss). If LP shares were priced while ignoring open positions, a depositor could buy in cheap the moment traders were winning and dilute Carol at her worst moment. Marking AUM makes entering and leaving the pool fair at any instant, and both `add_liquidity()` and `remove_liquidity()` take a minimum-out argument so a price move between signing and execution fails with `SlippageExceeded` instead of filling badly. Eleven more providers follow Carol in over the morning, and the pool opens for trading with 12,000 USDC of liquidity. `remove_liquidity()` makes one extra check: only **free** liquidity, `liquidity − reserved_liquidity`, may leave, and a larger withdrawal fails with `InsufficientLiquidity` (checked by `test_remove_liquidity_blocked_by_reserved`). Carol can never withdraw liquidity the pool has reserved for a winner. @@ -82,7 +82,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 funding is proportional to the imbalance and flows between the sides; the mechanism here teaches the accounting structure, an index plus per-position entry snapshots, which is the part that survives into every real implementation. The test `test_funding_charged_to_long` opens a lone long, lets two thousand seconds pass, and asserts the pool collected exactly the funding the index accrued. 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 the pool collected exactly the funding the index accrued. 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. The *Time* chapter covers why funding counts seconds and freshness counts slots. @@ -113,7 +113,7 @@ Here is where everyone ends up: - **Bob** turned 1,000 into 195 by being wrong and slow. - **Dave** collected 50 USDC for one well-timed transaction. - **Maria**'s `program_fees` holds 15, from two opens and one close (Bob's liquidation charges no close fee). She sweeps it with `collect_fees()`, which pays out what the fee ledger holds and sets it to zero. It is the only handler that needs her signature once the pool exists, and it refuses anyone else (`test_collect_fees_requires_authority`). -- **Carol** owns a twelfth of the pool, which absorbed Bob's 750 loss and paid Alice's 1,000 profit: down 250 on the trading, about 21 dollars of it hers, plus the losses and funding of the next thousand traders. +- **Carol** owns a twelfth of the pool, which absorbed Bob's 750 loss and paid Alice's 1,000 profit: down 250 on the trading, about 21 dollars of it hers. Her next twelfth comes from the traders who follow: their losses, less their profits, plus the net funding. ## The Math: One Formula per Concept, All Floored in the Pool's Favor Every calculation is checked u128 integer arithmetic, multiply before divide, and every division rounds in the pool's favor: `basis_points_of()` floors fees and margin thresholds, share minting and withdrawal amounts floor against AUM, and PnL truncates toward zero. Prices arrive at the oracle's fixed-point scale (10^8 here) and cancel out of the PnL formula, so positions settle in collateral minor units. The per-side scaled accumulator is what keeps the compute per transaction constant: each open adds size ÷ entry price (at `SIZE_PRECISION`) to its side. The pool's whole unrealized PnL is then two multiplications against the current price, so AUM, and with it LP share pricing and solvency, computes in constant time forever. @@ -152,7 +152,7 @@ This chapter added four concepts: - **Pre-funded solvency.** Reserve every position's maximum payout at open, cap profit at the reservation, and limit LP withdrawals to free liquidity, so the pool can never owe more than it holds. - **Constant-time accounting.** Per-side scaled accumulators and entry snapshots make solvency, AUM, and settlement computable without ever iterating open positions: realizable credit, account-local safety, and bounded progress, in miniature. -- **Funding.** A pool-wide index plus a per-position entry snapshot charges the crowded side a fee, the same accounting structure every production venue uses. +- **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. From 78fd99310d2c58d899c406ccefd8e8193818b9dc Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 19:44:46 +0000 Subject: [PATCH 2/7] Fundraiser figures: match the rewritten chapter Every fundraiser box gains claimed and open_contributor_accounts. One set of numbers throughout: Alice 1,000, Bob 800, Carol 500 + 500, eleven receipts to reach 9,000; 3,600 on the failed path. The claim figure no longer closes the fundraiser or vault. Two new figures: the receipts closing after the claim, then close_fundraiser(). Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- .../figures/fundraiser-account-map.svg | 42 +++-- .../figures/fundraiser-close-receipts.svg | 176 ++++++++++++++++++ .../figures/fundraiser-close.svg | 141 ++++++++++++++ .../figures/fundraiser-contribute.svg | 42 +++-- .../figures/fundraiser-initialize.svg | 28 +-- .../figures/fundraiser-refunds.svg | 51 +++-- .../figures/fundraiser-success.svg | 54 +++--- 7 files changed, 424 insertions(+), 110 deletions(-) create mode 100644 building-financial-products-in-solana/figures/fundraiser-close-receipts.svg create mode 100644 building-financial-products-in-solana/figures/fundraiser-close.svg diff --git a/building-financial-products-in-solana/figures/fundraiser-account-map.svg b/building-financial-products-in-solana/figures/fundraiser-account-map.svg index 82f4f283..2d2d4ee5 100644 --- a/building-financial-products-in-solana/figures/fundraiser-account-map.svg +++ b/building-financial-products-in-solana/figures/fundraiser-account-map.svg @@ -1,4 +1,4 @@ - + Every account the raise touches @@ -32,7 +32,7 @@ Alice's USDC - 100 USDC + 0 USDC @@ -43,7 +43,7 @@ Alice's receipt - amount: 900 USDC + amount: 1,000 USDC @@ -63,7 +63,7 @@ Bob's USDC - 100 USDC + 200 USDC @@ -74,7 +74,7 @@ Bob's receipt - amount: 900 USDC + amount: 800 USDC @@ -94,7 +94,7 @@ Carol's USDC - 100 USDC + 0 USDC @@ -105,7 +105,7 @@ Carol's receipt - amount: 900 USDC + amount: 1,000 USDC @@ -146,7 +146,7 @@ "FUNDRAISER" + MAKER'S ADDRESS - + fundraiser @@ -156,20 +156,22 @@ current_amount: 9,000 USDC time_started: day 0 duration: 7 days + claimed: false + open_contributor_accounts: 11 - - THE FUNDRAISER'S ADDRESS + - TOKEN PROGRAM + - USDC MINT ADDRESS - - - vault: fundraiser's USDC - - 9,000 USDC - authority: the fundraiser PDA + + THE FUNDRAISER'S ADDRESS + + TOKEN PROGRAM + + USDC MINT ADDRESS + + + vault: fundraiser's USDC + + 9,000 USDC + authority: the fundraiser PDA - …seven more backers, each with a 900 receipt - and their own USDC account + …eight more backers, each with their own + receipt and USDC account diff --git a/building-financial-products-in-solana/figures/fundraiser-close-receipts.svg b/building-financial-products-in-solana/figures/fundraiser-close-receipts.svg new file mode 100644 index 00000000..24a137f4 --- /dev/null +++ b/building-financial-products-in-solana/figures/fundraiser-close-receipts.svg @@ -0,0 +1,176 @@ + + The receipts close and return their rent + + + + + + Solana Devnet + + + + + + + USDC mint + + + + + + + Alice + + 1 SOL + + + + ALICE'S ADDRESS + + TOKEN PROGRAM + + USDC MINT + + + Alice's USDC + + + + "CONTRIBUTOR" + + THE FUNDRAISER + + ALICE'S ADDRESS + + + Alice's receipt + + amount: 1,000 USDC + + + + + + + + + Bob + + 1 SOL + + + + BOB'S ADDRESS + + TOKEN PROGRAM + + USDC MINT + + + Bob's USDC + + + + "CONTRIBUTOR" + + THE FUNDRAISER + + BOB'S ADDRESS + + + Bob's receipt + + amount: 800 USDC + + + + + + + + + Carol + + 1 SOL + + + + CAROL'S ADDRESS + + TOKEN PROGRAM + + USDC MINT + + + Carol's USDC + + + + "CONTRIBUTOR" + + THE FUNDRAISER + + CAROL'S ADDRESS + + + Carol's receipt + + amount: 1,000 USDC + + + + + + + + + + Maria (maker) + + + + MARIA'S ADDRESS + + TOKEN PROGRAM + + USDC MINT + + + Maria's USDC + + + + + + + fundraiser program + + initialize_fundraiser() + contribute() + check_contributions() + refund() + close_fundraiser() + close_contributor() + + + + + "FUNDRAISER" + + MAKER'S ADDRESS + + + fundraiser + + maker: Maria + mint_to_raise: USDC + amount_to_raise: 9,000 USDC + current_amount: 9,000 USDC + time_started: day 0 + duration: 7 days + claimed: true + open_contributor_accounts: 0 + + + + + THE FUNDRAISER'S ADDRESS + + TOKEN PROGRAM + + USDC MINT ADDRESS + + + vault: fundraiser's USDC + + + + each closed receipt + returns its rent + anyone may send close_contributor(): the rent can + only go to the backer, so Maria closes all eleven + without waiting on them + diff --git a/building-financial-products-in-solana/figures/fundraiser-close.svg b/building-financial-products-in-solana/figures/fundraiser-close.svg new file mode 100644 index 00000000..f6ff43c4 --- /dev/null +++ b/building-financial-products-in-solana/figures/fundraiser-close.svg @@ -0,0 +1,141 @@ + + The fundraiser and its vault close + + + + + + Solana Devnet + + + + + + + USDC mint + + + + + + + + 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 (maker) + + 1 SOL + + + + MARIA'S ADDRESS + + TOKEN PROGRAM + + USDC MINT + + + Maria's USDC + + + + + + + fundraiser program + + initialize_fundraiser() + contribute() + check_contributions() + refund() + close_fundraiser() + close_contributor() + + + + + "FUNDRAISER" + + MAKER'S ADDRESS + + + fundraiser + + maker: Maria + mint_to_raise: USDC + amount_to_raise: 9,000 USDC + current_amount: 9,000 USDC + time_started: day 0 + duration: 7 days + claimed: true + open_contributor_accounts: 0 + + + + + + + THE FUNDRAISER'S ADDRESS + + TOKEN PROGRAM + + USDC MINT ADDRESS + + + vault: fundraiser's USDC + + 0 USDC + authority: the fundraiser PDA + + + + + + rent from both + closed accounts + only now can Maria call initialize_fundraiser() + again at the same address + diff --git a/building-financial-products-in-solana/figures/fundraiser-contribute.svg b/building-financial-products-in-solana/figures/fundraiser-contribute.svg index 4befca68..ae62dde1 100644 --- a/building-financial-products-in-solana/figures/fundraiser-contribute.svg +++ b/building-financial-products-in-solana/figures/fundraiser-contribute.svg @@ -1,5 +1,5 @@ - - Alice contributes 900 USDC + + Alice contributes 1,000 USDC @@ -33,7 +33,7 @@ Alice's USDC - 100 USDC + 0 USDC @@ -44,7 +44,7 @@ Alice's receipt - amount: 900 USDC + amount: 1,000 USDC @@ -116,35 +116,37 @@ "FUNDRAISER" + MAKER'S ADDRESS - + fundraiser maker: Maria mint_to_raise: USDC amount_to_raise: 9,000 USDC - current_amount: 900 USDC + current_amount: 1,000 USDC time_started: day 0 duration: 7 days + claimed: false + open_contributor_accounts: 1 - - THE FUNDRAISER'S ADDRESS + - TOKEN PROGRAM + - USDC MINT ADDRESS - - - vault: fundraiser's USDC - - 900 USDC - authority: the fundraiser PDA + + THE FUNDRAISER'S ADDRESS + + TOKEN PROGRAM + + USDC MINT ADDRESS + + + vault: fundraiser's USDC + + 1,000 USDC + authority: the fundraiser PDA - - contribute() · 900 USDC - (Alice signs; no PDA signature needed) - + + contribute() · 1,000 USDC + (Alice signs; no PDA signature needed) + Alice fronts the rent for her receipt, which the program creates because she has not contributed before diff --git a/building-financial-products-in-solana/figures/fundraiser-initialize.svg b/building-financial-products-in-solana/figures/fundraiser-initialize.svg index bc9931de..f3b38c00 100644 --- a/building-financial-products-in-solana/figures/fundraiser-initialize.svg +++ b/building-financial-products-in-solana/figures/fundraiser-initialize.svg @@ -1,4 +1,4 @@ - + Maria starts the fundraiser @@ -103,7 +103,7 @@ "FUNDRAISER" + MAKER'S ADDRESS - + fundraiser @@ -113,21 +113,23 @@ current_amount: 0 USDC time_started: day 0 duration: 7 days + claimed: false + open_contributor_accounts: 0 NEW - - THE FUNDRAISER'S ADDRESS + - TOKEN PROGRAM + - USDC MINT ADDRESS - - - vault: fundraiser's USDC - - 0 USDC - authority: the fundraiser PDA - NEW + + THE FUNDRAISER'S ADDRESS + + TOKEN PROGRAM + + USDC MINT ADDRESS + + + vault: fundraiser's USDC + + 0 USDC + authority: the fundraiser PDA + NEW Maria signs, and fronts the rent for the fundraiser account and the vault; no tokens move diff --git a/building-financial-products-in-solana/figures/fundraiser-refunds.svg b/building-financial-products-in-solana/figures/fundraiser-refunds.svg index 78c9f107..4bd93e21 100644 --- a/building-financial-products-in-solana/figures/fundraiser-refunds.svg +++ b/building-financial-products-in-solana/figures/fundraiser-refunds.svg @@ -1,4 +1,4 @@ - + The target is missed @@ -111,8 +111,8 @@ Carol's receipt amount: 0 USDC - - + + @@ -150,46 +150,45 @@ "FUNDRAISER" + MAKER'S ADDRESS - + fundraiser maker: Maria mint_to_raise: USDC amount_to_raise: 9,000 USDC - current_amount: 900 USDC + current_amount: 800 USDC time_started: day 0 duration: 7 days + claimed: false + open_contributor_accounts: 1 - - THE FUNDRAISER'S ADDRESS + - TOKEN PROGRAM + - USDC MINT ADDRESS - - - vault: fundraiser's USDC - - 900 USDC - authority: the fundraiser PDA + + THE FUNDRAISER'S ADDRESS + + TOKEN PROGRAM + + USDC MINT ADDRESS + + + vault: fundraiser's USDC + + 800 USDC + authority: the fundraiser PDA - - 900 USDC - - 900 USDC - - 900 USDC + + 1,000 USDC + + 800 USDC + + 1,000 USDC each closed receipt returns its rent - day 7 has passed and - the target was missed; - 3,600 was raised, less - three 900 refunds; one - 900 receipt is still open + day 7 has passed and the target was missed: 3,600 was raised, less refunds + of 1,000, 800 and 1,000; Dave's 800 receipt is still open diff --git a/building-financial-products-in-solana/figures/fundraiser-success.svg b/building-financial-products-in-solana/figures/fundraiser-success.svg index d5c41c31..efe1e9bc 100644 --- a/building-financial-products-in-solana/figures/fundraiser-success.svg +++ b/building-financial-products-in-solana/figures/fundraiser-success.svg @@ -1,4 +1,4 @@ - + The target is met @@ -132,7 +132,7 @@ "FUNDRAISER" + MAKER'S ADDRESS - + fundraiser @@ -142,37 +142,29 @@ current_amount: 9,000 USDC time_started: day 0 duration: 7 days - - + claimed: true + open_contributor_accounts: 11 - - THE FUNDRAISER'S ADDRESS + - TOKEN PROGRAM + - USDC MINT ADDRESS - - - vault: fundraiser's USDC - - 0 USDC - authority: the fundraiser PDA - - + + THE FUNDRAISER'S ADDRESS + + TOKEN PROGRAM + + USDC MINT ADDRESS + + + vault: fundraiser's USDC + + 0 USDC + authority: the fundraiser PDA - - - 1. check_contributions() · 9,000 USDC - the vault's entire balance, signed by the fundraiser PDA - - rent from both - closed accounts - 3. fundraiser - closed - 2. vault closed - - the ten receipts are left standing: the claim - carries none of them, so each backer closes - their own with close_contributor() - + + + check_contributions() · 9,000 USDC + the vault's entire balance, signed by the fundraiser PDA + + the fundraiser and the empty vault stay open + until all eleven receipts are closed: each receipt's + address is derived from the fundraiser's + From 12f5f1aeaed7f8de5c97d09572a7787b1f7dc39b Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 19:57:24 +0000 Subject: [PATCH 3/7] Perps: name the initial margin and its link to maximum leverage The pool's opening threshold is initial_margin_bps (10%), above the 5% maintenance margin; maximum leverage is one over the initial margin. Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- .../perpetual-futures.md | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index 703e55a8..a09cf24a 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -14,13 +14,22 @@ That design decision creates the problem the program is organized around: the pr ## Creating the Market, Where the Parameters Are Checked Against Each Other -Maria creates the pool and is its `authority`; her revenue is the open and close fees that accrue to `program_fees`, and the program gives her no path to anyone's collateral. She calls `initialize_pool()` with the market's risk parameters: 10 basis-point open and close fees, 10x maximum leverage, a 5% maintenance margin, a 1% liquidation fee, and a 1% cap on oracle confidence. The funding rate is set here too, and no handler can change it once the pool exists. The handler validates each parameter against `BASIS_POINTS_DENOMINATOR`, and one cross-check prevents a dead state: `maintenance_margin_bps` must exceed `close_fee_bps`. Otherwise a position could exist that is too healthy to liquidate but too poor to pay its own close fee, and neither `close_position()` nor `liquidate_position()` would accept it, leaving its owner no way to get their collateral out. The test `test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin` checks it. So the requirement that every participant must be able to get their assets out of every state is enforced in the market's *configuration*, before any position exists. +Maria creates the pool and is its `authority`; her revenue is the open and close fees that accrue to `program_fees`, and the program gives her no path to anyone's collateral. She calls `initialize_pool()` with the market's risk parameters: 10 basis-point open and close fees, a 10% initial margin, a 5% maintenance margin, a 1% liquidation fee, a 1% cap on oracle confidence, and a 20% band around the pool's average price. The funding rate is set here too, and no handler can change any of them once the pool exists. + +The two margins are the two collateral thresholds every leveraged venue has, measured as a share of the position's size: + +- The **initial margin**, `initial_margin_bps`, is what a trader must have to open a position. A 10% initial margin means a 5,000 USDC position needs at least 500 USDC of collateral left after the open fee. +- The **maintenance margin**, `maintenance_margin_bps`, is the lower line an open position must stay above. When that position's equity falls to 250 USDC, 5% of its size, anyone may liquidate it. + +The initial margin and the **maximum leverage** are the same rule stated two ways: maximum leverage is one divided by the initial margin, so a 10% initial margin is 10x and a 5% initial margin would be 20x. Traders usually quote the leverage; the program stores the margin, so both thresholds are in the same unit. The gap between the two is the room a new position has before it can be liquidated, so `initialize_pool()` refuses an initial margin at or below the maintenance margin with `InitialMarginNotAboveMaintenance` (checked by `test_initialize_pool_rejects_initial_margin_at_or_below_maintenance`). + +The handler validates each parameter against `BASIS_POINTS_DENOMINATOR`, and a second cross-check prevents a dead state: `maintenance_margin_bps` must exceed `close_fee_bps`. Otherwise a position could exist that is too healthy to liquidate but too poor to pay its own close fee, and neither `close_position()` nor `liquidate_position()` would accept it, leaving its owner no way to get their collateral out. The test `test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin` checks it. So the requirement that every participant must be able to get their assets out of every state is enforced in the market's *configuration*, before any position exists. ![](figures/perps-create-pool.svg) Financial state on Solana is accounts, and this one transaction created the three the market needs. -The **pool** account lives at a PDA with seeds `"pool"`, the collateral mint, and the oracle feed, so one market exists per collateral-and-feed pair. Its struct, `Pool`, carries the risk parameters set at creation (`max_leverage`, `maintenance_margin_bps`, `open_fee_bps`, `close_fee_bps`, `liquidation_fee_bps`, `max_confidence_bps`, `funding_rate_per_second`) and the live accounting this chapter keeps returning to: +The **pool** account lives at a PDA with seeds `"pool"`, the collateral mint, and the oracle feed, so one market exists per collateral-and-feed pair. Its struct, `Pool`, carries the risk parameters set at creation (`initial_margin_bps`, `maintenance_margin_bps`, `open_fee_bps`, `close_fee_bps`, `liquidation_fee_bps`, `max_confidence_bps`, `max_price_deviation_bps`, `funding_rate_per_second`) and the live accounting this chapter keeps returning to: - `liquidity`: what the LPs own. It grows with deposits, trader losses, and funding; it shrinks with withdrawals and trader profits. - `reserved_liquidity`: the portion of `liquidity` currently pledged as the maximum payout of open positions. @@ -28,6 +37,7 @@ The **pool** account lives at a PDA with seeds `"pool"`, the collateral mint, an - `program_fees`: Maria's accrued fees, separate again. - `long_size`, `short_size`, `long_size_scaled`, `short_size_scaled`: per-side open-interest accumulators, which make solvency checkable without iteration. - `cumulative_funding` and `last_funding_timestamp`: the funding index. +- `average_price`, `last_oracle_price` and `average_price_timestamp`: the pool's own running average of the oracle's price, described below. The pool is the mint authority of the **LP share mint** (seeds `"lp_mint"`) and the token authority of the **custody vault** (seeds `"vault"`), a single token account holding LP liquidity, trader collateral, and fees together, and signs for both with its own seeds. The vault holds everyone's tokens together, and the three ledger fields, `liquidity`, `total_collateral`, and `program_fees`, record how much of it belongs to whom. As in the *Fundraiser* chapter, every handler computes amounts from those fields, never from the vault balance. @@ -51,10 +61,9 @@ A stale price here invites arbitrage. Anyone who can trade a leveraged market at ## Opening a Long, and the Pool Reserves the Best Case -Alice thinks the quarter goes NVIDIA's way and goes long; Bob believes the opposite and will open the mirror-image short. Alice calls `open_position()`: side Long, 1,000 USDC collateral, 5,000 USDC size, and an acceptable-price bound (her slippage check; a long opening above it fails with `SlippageExceeded`). Size five times her collateral means **5x leverage**: every 1% move in NVDAx moves her equity by about 5%. The handler charges the open fee on **notional size, not collateral**: 10 basis points of 5,000 is 5 USDC, so her working collateral is 995. Fees scale with exposure because exposure, not deposit, is what the pool underwrites. Then the handler makes three checks, in order, and refuses the position if any fails: +Alice thinks the quarter goes NVIDIA's way and goes long; Bob believes the opposite and will open the mirror-image short. Alice calls `open_position()`: side Long, 1,000 USDC collateral, 5,000 USDC size, and an acceptable-price bound (her slippage check; a long opening above it fails with `SlippageExceeded`). Size five times her collateral means **5x leverage**: every 1% move in NVDAx moves her equity by about 5%. The handler charges the open fee on **notional size, not collateral**: 10 basis points of 5,000 is 5 USDC, so her working collateral is 995. Fees scale with exposure because exposure, not deposit, is what the pool underwrites. Then the handler makes two checks, in order, and refuses the position if either fails: -- **Leverage**: size must not exceed net collateral times `max_leverage`. 5,000 against 995 × 10 = 9,950 passes; her effective leverage is about 5x. Eleven-x fails with `LeverageTooHigh` (checked by `test_open_rejects_excess_leverage`). -- **Born healthy**: net collateral must exceed the maintenance margin on her size, 5% of 5,000 = 250 USDC, else `PositionNotHealthy`. A position must not be liquidatable in the transaction that creates it. +- **Initial margin**: net collateral must be at least `initial_margin_bps` of the size: 10% of 5,000 is 500, and her 995 passes. The largest position 995 USDC can open is 9,950 USDC, which is the 10x maximum; she is opening about 5x. Anything larger fails with `InitialMarginNotMet` (checked by `test_open_rejects_position_below_initial_margin`). Because the initial margin is above the maintenance margin, no position can be liquidatable in the transaction that creates it. - **The reservation**: `reserved_liquidity` grows by her full size, 5,000, and the sum must stay within `liquidity`, else `InsufficientLiquidity` (checked by `test_open_rejects_when_pool_cannot_back_it`). By reserving the notional, the pool sets aside the most it could ever owe her, because **profit on a close is capped at the position's size**. The cap has a cost for Alice: if NVDAx tripled to $300, her raw profit of 10,000 USDC would be paid as 5,000. In exchange, every winner's payout is pre-funded before the position exists, so the pool can never owe more than it holds. The test `test_profit_capped_at_reserved_notional` runs the tripling and asserts the cap. Production venues add insurance funds and auto-deleveraging on top of this reservation. @@ -126,7 +135,7 @@ The example's design notes point at Percolator, Anatoly Yakovenko's Kani-checked - **Account-local safety**: any position can be settled by touching that position and the pool, never a third account. Here: the entry-snapshot fields on `Position`. - **Bounded progress**: no operation's compute grows with the number of open positions. Here: the per-side accumulators and one-at-a-time liquidation. -The book's standing invariants hold here too. The vault balance always equals `liquidity + total_collateral + program_fees` plus dust; share value cannot be moved by donating tokens to the vault, because AUM reads tracked state, not balances; and every position born healthy can, at all times, be closed by either `close_position()` or `liquidate_position()`. +The book's standing invariants hold here too. The vault balance always equals `liquidity + total_collateral + program_fees` plus dust; share value cannot be moved by donating tokens to the vault, because AUM reads tracked state, not balances; and every position opened above the initial margin can, at all times, be closed by either `close_position()` or `liquidate_position()`. ## The Failure Modes: Leverage Amplifies Whatever You Got Wrong @@ -136,7 +145,7 @@ The book's standing invariants hold here too. The vault balance always equals `l - **Winning too big**: profit beyond the reservation is clipped by the cap rather than bankrupting the pool, and traders know the cap before they open. - **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. - **Double close, double liquidation**: the position account closes when the position is closed or liquidated; the second call finds no account. -- **Dangerous configurations**: parameter validation at pool creation, including the maintenance-versus-close-fee cross-check. +- **Dangerous configurations**: parameter validation at pool creation, including the two cross-checks: the initial margin above the maintenance margin, and the maintenance margin above the close fee. ## Testing Considerations From 5c1efc775b7db64aae62213e7edeb84306d3c42f Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 20:11:45 +0000 Subject: [PATCH 4/7] Perps: the pool averages the oracle price and trades only near the average New section on average_price, last_oracle_price and the 20% band: which handlers check it, why liquidations do not, how update_price_average() lets the average catch up, and the tests that run a manipulated read after an idle spell. Failure modes, testing and What You Learnt follow. Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- .../perpetual-futures.md | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index a09cf24a..b03b1028 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -49,7 +49,7 @@ Carol is a liquidity provider. She gains what losing traders lose and pays what ## 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. The pool records one **oracle feed** account at creation, the mechanism from the *Offchain Truth* chapter, at production scale. The feed carries a price written onchain and signed by the feed network that published it, read with the same five checks the *Prop AMM* chapter ran. In the companion repository the 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 feed network's account, decoded and verified with that network's own crate. Either way the program runs five checks on what it reads, in order: +Every trading and liquidity handler begins the same way: read the oracle, validate it, accrue funding, and update the pool's average of the price. The pool records one **oracle feed** account at creation, the mechanism from the *Offchain Truth* chapter, at production scale. The feed carries a price written onchain and signed by the feed network that published it, read with the same five checks the *Prop AMM* chapter ran. In the companion repository the 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 feed network's account, decoded and verified with that network's own crate. Either way the program runs five checks on what it reads, in order: - the account is long enough to contain a price (`OracleDataTooShort`), - the price is positive (`NonPositivePrice`), @@ -59,6 +59,24 @@ Every trading and liquidity handler begins the same way: read the oracle, valida A stale price here invites arbitrage. Anyone who can trade a leveraged market at a stale price against a live market elsewhere drains the pool with no risk. The tests `test_stale_price_rejected` and `test_wide_oracle_confidence_rejected` check both refusals. +## The Pool Averages the Price Itself, and Trades Only Near the Average + +The five checks refuse a price that is malformed, stale, or uncertain. A 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: + +- The elapsed time since `average_price_timestamp`, capped at `PRICE_AVERAGE_WINDOW_SECONDS` (600 seconds), is the weight. +- The average moves toward `last_oracle_price` by that weight's share of the window: (`last_oracle_price` − `average_price`) × elapsed ÷ 600, multiplied before dividing. +- Then the price just read becomes `last_oracle_price`, and the timestamp becomes now. + +A price only starts counting from the moment it is read. A manipulated price moves the average only if a later read still sees it, by the seconds between the two reads, and any read of the real price in between replaces it. `test_one_manipulated_read_after_idle_does_not_move_average` leaves the pool idle for 15 minutes, reads a price of $160, and asserts the average is still exactly $100. + +`open_position()`, `close_position()`, `add_liquidity()` and `remove_liquidity()` compare the oracle's price with the stored average before folding it in. A price more than `max_price_deviation_bps` from the average, 20% in this pool, fails with `PriceOutsideBand` (checked by `test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band` and `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`). + +`liquidate_position()` folds the price in without the band check. A genuine crash is when positions go underwater, and if liquidations waited for the average to catch up, the losses would grow in the LPs' pool while they waited. `test_liquidation_runs_outside_band` checks that a liquidation succeeds where a close at the same price is refused. + +After a genuine move, the average has to catch up before anyone can trade again. Anyone may call `update_price_average()`, which reads and checks the oracle, accrues funding, and folds the price in, with no band check. Calling it as time passes pulls the average toward the new price: `test_price_average_catches_up_after_genuine_move` moves the price to $130 and calls it every two minutes, and trading reopens on the third call, with the average at $110.80. Traders who want to trade, LPs who want to withdraw, and liquidators all have a reason to send it. + ## Opening a Long, and the Pool Reserves the Best Case Alice thinks the quarter goes NVIDIA's way and goes long; Bob believes the opposite and will open the mirror-image short. Alice calls `open_position()`: side Long, 1,000 USDC collateral, 5,000 USDC size, and an acceptable-price bound (her slippage check; a long opening above it fails with `SlippageExceeded`). Size five times her collateral means **5x leverage**: every 1% move in NVDAx moves her equity by about 5%. The handler charges the open fee on **notional size, not collateral**: 10 basis points of 5,000 is 5 USDC, so her working collateral is 995. Fees scale with exposure because exposure, not deposit, is what the pool underwrites. Then the handler makes two checks, in order, and refuses the position if either fails: @@ -139,7 +157,7 @@ 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 five checks, the confidence cap, and the one trust decision the program leaves visible: the pool creator chooses the feed, and the feed account's owner is deliberately unchecked, so whoever controls that feed sets every price the pool settles at. +- **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 five checks, the confidence cap, and the band around the pool's own average price. One trust decision stays visible: the pool creator chooses the feed, and the feed account's owner is deliberately unchecked. Whoever controls that feed sets the prices the pool settles at, held within the band and moving only as fast as the average follows. - **The unliquidated-position gap**: liquidations that pay nothing do not happen, and the LPs absorb the loss. The example documents this gap, and it is why production venues add an insurance fund. - **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 smaller than `MINIMUM_LIQUIDITY` is refused with `DepositTooSmall`. - **Winning too big**: profit beyond the reservation is clipped by the cap rather than bankrupting the pool, and traders know the cap before they open. @@ -151,16 +169,20 @@ The book's standing invariants hold here too. The vault balance always equals `l The suite is the largest in the companion repository: a mock oracle the tests set prices on, and a `Market` helper that builds the pool, warps slots, and re-derives position PDAs. The pattern to copy is **one test per invariant, named as the sentence it checks**: `test_profit_capped_at_reserved_notional`, `test_remove_liquidity_blocked_by_reserved`, `test_healthy_position_cannot_be_liquidated`, `test_stale_price_rejected`, `test_collect_fees_requires_authority`. +The band tests set a price, move the clock, and assert the average to the minor unit: `test_single_update_moves_average_by_elapsed_fraction` checks that one read moves nothing and the next moves the average by exactly the elapsed share of the window. + The happy-path tests (`test_close_long_in_profit`, `test_close_long_in_loss`, `test_close_short_in_profit`) check the same hand-computed payouts this chapter walked through. `test_liquidation_of_underwater_long` runs Dave's whole waterfall. When a future change breaks an invariant, the failing test's name says which one. ## What You Learnt from Building the Perpetual Futures Venue A traditional futures exchange is a matching engine, a clearing house with a margin department, a funding desk, and a floor of liquidation clerks. One reservation does most of that work: every position reserves its maximum payout when it opens, so the pool can never owe more than it holds. The pool is the clearing house and the counterparty, the maintenance margin and the reservation are its margin department, the funding index is the funding desk, and a liquidator's bot is the liquidation clerk, paid per correction. -This chapter added four concepts: +This chapter added six concepts: - **Pre-funded solvency.** Reserve every position's maximum payout at open, cap profit at the reservation, and limit LP withdrawals to free liquidity, so the pool can never owe more than it holds. - **Constant-time accounting.** Per-side scaled accumulators and entry snapshots make solvency, AUM, and settlement computable without ever iterating open positions: realizable credit, account-local safety, and bounded progress, in miniature. +- **Two margins.** Require an initial margin to open a position and a lower maintenance margin to keep it, so a new position has room to move before it can be liquidated. Maximum leverage is one divided by the initial margin. +- **A price band around the program's own average.** Keep a time-weighted average of the oracle's price, credit each interval to the price seen at its start, and refuse trades at prices too far from the average, while letting liquidations through. - **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. From e88cde9b67563c8fe75c98dc0a3a7f49c2ccc6c3 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 20:14:11 +0000 Subject: [PATCH 5/7] Perps figures: show the pool's average_price Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- .../figures/perps-account-map.svg | 37 +++++++++-------- .../figures/perps-create-pool.svg | 41 ++++++++++--------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/building-financial-products-in-solana/figures/perps-account-map.svg b/building-financial-products-in-solana/figures/perps-account-map.svg index 353b850e..cffa469b 100644 --- a/building-financial-products-in-solana/figures/perps-account-map.svg +++ b/building-financial-products-in-solana/figures/perps-account-map.svg @@ -166,7 +166,7 @@ "POOL" + COLLATERAL MINT + ORACLE FEED ADDRESS - + Pool @@ -177,25 +177,26 @@ long_size: 5,000 short_size: 5,000 cumulative_funding: 0 + average_price: $100 - - "VAULT" + POOL - - - custody vault - - 14,000 USDC - authority: the pool - - - - "LP_MINT" + POOL - - - LP share mint - - mint authority: the pool + + "VAULT" + POOL + + + custody vault + + 14,000 USDC + authority: the pool + + + + "LP_MINT" + POOL + + + LP share mint + + mint authority: the pool 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 19289a13..dac19469 100644 --- a/building-financial-products-in-solana/figures/perps-create-pool.svg +++ b/building-financial-products-in-solana/figures/perps-create-pool.svg @@ -1,4 +1,4 @@ - + Maria creates the pool Solana Devnet @@ -113,7 +113,7 @@ "POOL" + COLLATERAL MINT + ORACLE FEED ADDRESS - + Pool @@ -125,28 +125,29 @@ long_size: 0 short_size: 0 cumulative_funding: 0 + average_price: $100 - - "VAULT" + POOL - - - custody vault - - 0 USDC - NEW - authority: the pool + + "VAULT" + POOL + + + custody vault + + 0 USDC + NEW + authority: the pool - - "LP_MINT" + POOL - - - LP share mint - - supply: 0 LP - NEW - mint authority: the pool + + "LP_MINT" + POOL + + + LP share mint + + supply: 0 LP + NEW + mint authority: the pool From 3863b9bd83529acb319517b55fe63187343977aa Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 21:16:16 +0000 Subject: [PATCH 6/7] Perps: profit is a junior claim paid by haircut; an insurance fund before the LPs Follows the haircut risk model (Percolator) in the companion repository: nothing is reserved at open and profit has no cap; each winner is paid profit x h, never more than liquidity + insurance_fund, and never refused; a profit warm-up; half of each fee to an insurance fund that pays a bankrupt position's deficit before the LPs; an uncollectible liquidation fee is forgiven. Glossary: haircut, profit warm-up; reservation removed. Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- appendices/glossary.md | 16 ++-- .../perpetual-futures.md | 88 ++++++++++++------- solana-building-blocks/time.md | 2 +- 3 files changed, 68 insertions(+), 38 deletions(-) diff --git a/appendices/glossary.md b/appendices/glossary.md index 715ccb65..e3563a05 100644 --- a/appendices/glossary.md +++ b/appendices/glossary.md @@ -54,7 +54,7 @@ The address whose signature a particular action requires. A program records the ### auto-deleveraging (ADL) -A perpetual futures venue's last resort when a loss exceeds the collateral behind it and no insurance fund can cover the rest: the most profitable leveraged positions on the winning side are closed at the mark, paid out early, and their exposure retired. See the *Perpetual Futures* chapter. +A perpetual futures venue's last resort when a loss exceeds the collateral behind it and no insurance fund can cover the rest: the most profitable leveraged positions on the winning side are closed at the mark, paid out early, and their exposure retired. The *Perpetual Futures* chapter's program uses a [haircut](#haircut) instead. ### base token @@ -268,6 +268,10 @@ One of two competing chains, formed when validators briefly build on different b The periodic payment between longs and shorts on a perpetual futures market that keeps the perpetual's price close to the index price. +### haircut + +On a perpetual futures pool, the fraction of a winner's profit the pool pays when it cannot pay every winner in full: what the pool holds to pay profits, divided by what traders are owed, capped at 1. Every winner who closes is paid the same fraction, so no early closer is paid in full while the rest go unpaid. See the *Perpetual Futures* chapter. + ### half-up Rounding to the nearest whole unit with exact halves going up, which is what Rust's `round` does. Never used on amounts in this book. See [rounding](#rounding). @@ -318,7 +322,7 @@ The onchain function that processes an instruction. This book always says "instr ### insurance fund -Capital a venue sets aside to absorb losses larger than the collateral behind them, before those losses reach the liquidity providers. The tier above it is [auto-deleveraging](#auto-deleveraging-adl). See the *Perpetual Futures* chapter. +Capital a venue sets aside, usually from a share of its fees, to absorb losses larger than the collateral behind them before those losses reach the liquidity providers. What the fund cannot cover is taken from the liquidity providers, then from winners by a [haircut](#haircut) or [auto-deleveraging](#auto-deleveraging-adl). See the *Perpetual Futures* chapter. ### invariant @@ -524,6 +528,10 @@ The order-matching fairness rule: better-priced orders fill first, and among equ In proportion to stake. Winners split a [parimutuel](#parimutuel) pool pro rata by what they bet, and shares are minted pro rata against the pool they join. +### profit warm-up + +On a perpetual futures pool, the number of slots a position must be open before its profit can be taken. A trader who pushes the oracle to a false price cannot open a position and cash out the profit before the price has had that long to correct. Losses are not delayed. See the *Perpetual Futures* chapter. + ### program Executable code deployed onchain; the thing this book builds. Called a "smart contract" on other chains. @@ -564,10 +572,6 @@ A per-participant account, usually at a PDA, recording what the program owes one The deposit an account holds to pay for its own storage. Rent-exempt accounts hold enough to stay forever; closing an account reclaims the deposit. -### reservation - -On a perpetual futures pool, the liquidity pledged when a position opens as the most that position could ever be paid: profit is capped at it, liquidity providers cannot withdraw against it, and a position the pool cannot back is refused outright. See the *Perpetual Futures* chapter. - ### reserve In a lending market, the per-asset pool: its liquidity vault, its share mint, its interest state, and its risk configuration. diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index b03b1028..70e357ce 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -14,7 +14,15 @@ That design decision creates the problem the program is organized around: the pr ## Creating the Market, Where the Parameters Are Checked Against Each Other -Maria creates the pool and is its `authority`; her revenue is the open and close fees that accrue to `program_fees`, and the program gives her no path to anyone's collateral. She calls `initialize_pool()` with the market's risk parameters: 10 basis-point open and close fees, a 10% initial margin, a 5% maintenance margin, a 1% liquidation fee, a 1% cap on oracle confidence, and a 20% band around the pool's average price. The funding rate is set here too, and no handler can change any of them once the pool exists. +Maria creates the pool and is its `authority`; her revenue is the share of the open and close fees that accrues to `program_fees`, and the program gives her no path to anyone's collateral. She calls `initialize_pool()` with the market's risk parameters: + +- 10 basis-point open and close fees, half of each paid into an insurance fund, +- a 10% initial margin and a 5% maintenance margin, +- a 1% liquidation fee, +- a 1% cap on oracle confidence and a 20% band around the pool's average price, +- and a 10-slot profit warm-up. + +The funding rate is set here too, and no handler can change any of them once the pool exists. The two margins are the two collateral thresholds every leveraged venue has, measured as a share of the position's size: @@ -29,23 +37,23 @@ The handler validates each parameter against `BASIS_POINTS_DENOMINATOR`, and a s Financial state on Solana is accounts, and this one transaction created the three the market needs. -The **pool** account lives at a PDA with seeds `"pool"`, the collateral mint, and the oracle feed, so one market exists per collateral-and-feed pair. Its struct, `Pool`, carries the risk parameters set at creation (`initial_margin_bps`, `maintenance_margin_bps`, `open_fee_bps`, `close_fee_bps`, `liquidation_fee_bps`, `max_confidence_bps`, `max_price_deviation_bps`, `funding_rate_per_second`) and the live accounting this chapter keeps returning to: +The **pool** account lives at a PDA with seeds `"pool"`, the collateral mint, and the oracle feed, so one market exists per collateral-and-feed pair. Its struct, `Pool`, carries the risk parameters set at creation (`initial_margin_bps`, `maintenance_margin_bps`, `open_fee_bps`, `close_fee_bps`, `liquidation_fee_bps`, `max_confidence_bps`, `max_price_deviation_bps`, `insurance_fee_bps`, `profit_warmup_slots`, `funding_rate_per_second`) and the live accounting this chapter keeps returning to: - `liquidity`: what the LPs own. It grows with deposits, trader losses, and funding; it shrinks with withdrawals and trader profits. -- `reserved_liquidity`: the portion of `liquidity` currently pledged as the maximum payout of open positions. - `total_collateral`: traders' collateral held for open positions. It is tracked separately from `liquidity` because it belongs to the traders, not the LPs. - `program_fees`: Maria's accrued fees, separate again. +- `insurance_fund`: the share of every fee set aside to pay losses larger than a position's collateral, and profits larger than `liquidity`. - `long_size`, `short_size`, `long_size_scaled`, `short_size_scaled`: per-side open-interest accumulators, which make solvency checkable without iteration. - `cumulative_funding` and `last_funding_timestamp`: the funding index. - `average_price`, `last_oracle_price` and `average_price_timestamp`: the pool's own running average of the oracle's price, described below. -The pool is the mint authority of the **LP share mint** (seeds `"lp_mint"`) and the token authority of the **custody vault** (seeds `"vault"`), a single token account holding LP liquidity, trader collateral, and fees together, and signs for both with its own seeds. The vault holds everyone's tokens together, and the three ledger fields, `liquidity`, `total_collateral`, and `program_fees`, record how much of it belongs to whom. As in the *Fundraiser* chapter, every handler computes amounts from those fields, never from the vault balance. +The pool is the mint authority of the **LP share mint** (seeds `"lp_mint"`) and the token authority of the **custody vault** (seeds `"vault"`), a single token account holding LP liquidity, trader collateral, and fees together, and signs for both with its own seeds. The vault holds everyone's tokens together, and the four ledger fields, `liquidity`, `total_collateral`, `program_fees`, and `insurance_fund`, record how much of it belongs to whom. As in the *Fundraiser* chapter, every handler computes amounts from those fields, never from the vault balance. ## Liquidity Providers Fund the Pool and Get Shares Priced at Mark Carol is a liquidity provider. She gains what losing traders lose and pays what winning traders win, so she holds whichever side of NVDAx the traders are net against. Funding adds to her return only by the difference between what the heavier side pays the pool and what the pool pays the lighter side. She calls `add_liquidity()` with her whole 1,000 USDC. She is the first depositor, so her shares are her deposit minus `MINIMUM_LIQUIDITY`: a withheld 1,000 minor units, a tenth of a cent, that no one will ever own. That is the same first-depositor defense the *Constant-Product AMM* chapter built. Both `add_liquidity()` and `remove_liquidity()` count the withheld shares in the supply they divide by, so their slice of the pool stays locked for good, and the failure modes below show the attack those withheld shares stop. The *Managed Fund* chapter ignores donations instead. Later deposits mint shares pro rata against **assets under management marked to market**, rather than against the vault balance or `liquidity` alone: `liquidity` minus the traders' current unrealized profit (or plus their unrealized loss). If LP shares were priced while ignoring open positions, a depositor could buy in cheap the moment traders were winning and dilute Carol at her worst moment. Marking AUM makes entering and leaving the pool fair at any instant, and both `add_liquidity()` and `remove_liquidity()` take a minimum-out argument so a price move between signing and execution fails with `SlippageExceeded` instead of filling badly. Eleven more providers follow Carol in over the morning, and the pool opens for trading with 12,000 USDC of liquidity. -`remove_liquidity()` makes one extra check: only **free** liquidity, `liquidity − reserved_liquidity`, may leave, and a larger withdrawal fails with `InsufficientLiquidity` (checked by `test_remove_liquidity_blocked_by_reserved`). Carol can never withdraw liquidity the pool has reserved for a winner. +`remove_liquidity()` makes one extra check: a withdrawal can never take more than `liquidity`, the tokens the LPs actually own, even when traders' unrealized losses make the shares worth more. A larger withdrawal fails with `InsufficientLiquidity` (checked by `test_remove_liquidity_capped_at_liquidity`). An unrealized loss is not yet in the vault, so Carol cannot take it out until the losing trader settles it. ## The Price Comes From Outside, So Every Handler Checks It Before Using It @@ -77,24 +85,15 @@ A price only starts counting from the moment it is read. A manipulated price mov After a genuine move, the average has to catch up before anyone can trade again. Anyone may call `update_price_average()`, which reads and checks the oracle, accrues funding, and folds the price in, with no band check. Calling it as time passes pulls the average toward the new price: `test_price_average_catches_up_after_genuine_move` moves the price to $130 and calls it every two minutes, and trading reopens on the third call, with the average at $110.80. Traders who want to trade, LPs who want to withdraw, and liquidators all have a reason to send it. -## Opening a Long, and the Pool Reserves the Best Case +## Opening a Long Sets Nothing Aside -Alice thinks the quarter goes NVIDIA's way and goes long; Bob believes the opposite and will open the mirror-image short. Alice calls `open_position()`: side Long, 1,000 USDC collateral, 5,000 USDC size, and an acceptable-price bound (her slippage check; a long opening above it fails with `SlippageExceeded`). Size five times her collateral means **5x leverage**: every 1% move in NVDAx moves her equity by about 5%. The handler charges the open fee on **notional size, not collateral**: 10 basis points of 5,000 is 5 USDC, so her working collateral is 995. Fees scale with exposure because exposure, not deposit, is what the pool underwrites. Then the handler makes two checks, in order, and refuses the position if either fails: +Alice thinks the quarter goes NVIDIA's way and goes long; Bob believes the opposite and will open the mirror-image short. Alice calls `open_position()`: side Long, 1,000 USDC collateral, 5,000 USDC size, and an acceptable-price bound (her slippage check; a long opening above it fails with `SlippageExceeded`). Size five times her collateral means **5x leverage**: every 1% move in NVDAx moves her equity by about 5%. The handler charges the open fee on **notional size, not collateral**: 10 basis points of 5,000 is 5 USDC, so her working collateral is 995. Fees scale with exposure because exposure, not deposit, is what the pool underwrites. Then the handler makes one check, and refuses the position if it fails: - **Initial margin**: net collateral must be at least `initial_margin_bps` of the size: 10% of 5,000 is 500, and her 995 passes. The largest position 995 USDC can open is 9,950 USDC, which is the 10x maximum; she is opening about 5x. Anything larger fails with `InitialMarginNotMet` (checked by `test_open_rejects_position_below_initial_margin`). Because the initial margin is above the maintenance margin, no position can be liquidatable in the transaction that creates it. -- **The reservation**: `reserved_liquidity` grows by her full size, 5,000, and the sum must stay within `liquidity`, else `InsufficientLiquidity` (checked by `test_open_rejects_when_pool_cannot_back_it`). - -By reserving the notional, the pool sets aside the most it could ever owe her, because **profit on a close is capped at the position's size**. The cap has a cost for Alice: if NVDAx tripled to $300, her raw profit of 10,000 USDC would be paid as 5,000. In exchange, every winner's payout is pre-funded before the position exists, so the pool can never owe more than it holds. The test `test_profit_capped_at_reserved_notional` runs the tripling and asserts the cap. Production venues add insurance funds and auto-deleveraging on top of this reservation. -> **Where a loss too big to pay goes.** A leveraged position can lose more than the collateral standing behind it, and somebody still has to pay the winner on the other side. Venues answer with a waterfall, where each tier takes the shortfall from someone different: -> -> - **Liquidation** takes it from the loser, while collateral remains. That is the job `liquidate_position()` does here. -> - **An insurance fund** takes it from a pot the venue filled out of fees, so that what the collateral could not cover does not come from the LPs. -> - **Auto-deleveraging** takes it from the winners. The venue closes the most profitable leveraged positions on the winning side at the mark, pays them out early, and retires exposure nobody can fund. -> -> This program stops at the first tier and caps profit at the reservation, so the shortfall can never open in the first place. An insurance fund and auto-deleveraging do not make a venue's losses smaller. They name, before the loss happens, which users pay for it. +The pool sets nothing aside for her, and the size of the pool does not limit the size of her position (`test_open_allowed_without_full_backing`). Whether the pool can pay her profit is decided when she closes, by the haircut described below. Her 5 USDC fee is split by `insurance_fee_bps`: half, 2.50, goes to `program_fees` for Maria and half to `insurance_fund`. The handler also records the slot the position opened in, `entry_slot`, for the profit warm-up. -Her transaction ends with the only token movement: 1,000 USDC from Alice's token account into the vault. Bob opens his mirror-image short the same way: 1,000 collateral, 5,000 size, entry $100, another 5 USDC fee to `program_fees`, another 5,000 reserved. +Her transaction ends with the only token movement: 1,000 USDC from Alice's token account into the vault. Bob opens his mirror-image short the same way: 1,000 collateral, 5,000 size, entry $100, and another 5 USDC fee, split the same way. ![](figures/perps-open-position.svg) @@ -126,20 +125,45 @@ At $115, Bob's equity is 995 − 750 = 245, under the 250 maintenance line. He s - Bob is refunded the rest: 195 USDC, plus the rent from his position account, which closes. - The LPs absorb the difference between Bob's collateral and what was paid out: 995 − 245 = 750 USDC to `liquidity`. -The pool cannot sell Bob's position; a program never acts on its own, somebody has to call it, so the design pays outsiders to call it at the right moment. Dave is the crank pattern from the *Lending and Borrowing* chapter with a profit motive. The example documents one gap: the liquidation fee comes out of *remaining equity*, so a position that gaps straight through zero pays Dave nothing, and positions nobody liquidates become losses the LPs absorb. Production venues fund the liquidator from an insurance fund so the incentive never disappears. +The pool cannot sell Bob's position; a program never acts on its own, somebody has to call it, so the design pays outsiders to call it at the right moment. Dave is the crank pattern from the *Lending and Borrowing* chapter with a profit motive. + +If the price had jumped far enough to take Bob's equity below zero before anyone acted, there would be no equity to pay Dave from. The program follows Percolator here: the part of the fee the equity cannot cover is forgiven, and neither the insurance fund nor the LPs pay it. Anyone may still liquidate the position, and the people losing while it stays open have their own reason to: the loss beyond Bob's collateral, called the **deficit**, is paid from `insurance_fund` first and from the LPs' `liquidity` only after the fund is empty. `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` liquidates such a position: Dave receives nothing, the position closes, the insurance fund pays 25 of a 40 USDC deficit, and the LPs bear the other 15. + +> **Where a loss too big to pay goes.** A leveraged position can lose more than the collateral standing behind it, and somebody still has to pay the winner on the other side. Venues answer with a waterfall, where each tier takes the shortfall from someone different: +> +> - **Liquidation** takes it from the loser, while collateral remains. That is the job `liquidate_position()` does here. +> - **An insurance fund** takes it from a pot the venue filled out of fees. Here, `insurance_fund` pays a bankrupt position's deficit before any of it reaches the LPs. +> - **The LPs** take what the fund cannot. +> - **A haircut** takes it from the winners. Here, when the pool cannot pay every winner in full, each winner who closes is paid the same fraction of their profit, as the next section explains. +> +> Some venues add **auto-deleveraging**, closing the most profitable positions on the winning side early; this program does not. None of these tiers makes a venue's losses smaller. They name, before the loss happens, which users pay for it. ![](figures/perps-liquidation.svg) -## Closing at $120, and the Pool Pays From Its Reservation +## Profit Is Paid Only as Far as the Pool Can Pay It + +A trader's collateral is **senior**: it always comes back, less losses and fees. Their profit is **junior**: it is paid only as far as the pool holds the tokens to pay it. This is the risk model of Percolator, Anatoly Yakovenko's Kani-checked perp risk engine. When `close_position()` settles a winning position, it first computes the **haircut ratio**, `h`: + +- The backing is `liquidity + insurance_fund`. +- The liability is the traders' unrealized profit across the pool, from the same per-side accumulators that price LP shares, at the current price. If the closing position's own profit is larger, the liability is that profit instead. +- `h` is the backing divided by the liability, capped at 1, in fixed point at `HAIRCUT_PRECISION`. + +The winner is paid their profit × `h`, rounded down. While the pool can pay everyone, `h` is 1 and every profit is paid in full; `test_profit_runs_uncapped_when_backed` checks a profit with no cap on it. When a sharp move leaves traders owed more than the pool holds, every winner who closes is paid the same fraction: `test_haircut_scales_profit_when_pool_stressed` opens two longs owed 1,800 USDC between them against 900 of liquidity, and each is paid exactly half. The withheld part stays in the pool, and `h` rises again as losing positions settle their losses into it. A loss is never haircut. + +The handler computes `h` before the closing position leaves the accumulators, so the closer is counted among the winners being paid rather than leaving the others to absorb the shortfall. The payout can never exceed `liquidity + insurance_fund`, so a winning close is never refused for lack of backing, even when losing positions still open on the other side make the pool's net liability smaller than the closer's profit; `test_winner_offset_by_open_loser_is_paid_not_refused` checks that case. The profit is paid from `liquidity` first and from `insurance_fund` for any remainder (`test_insurance_pays_profit_beyond_liquidity`). + +A profit can only be taken once the position has been open for `profit_warmup_slots`, counted from `entry_slot`; before then the close fails with `ProfitNotMatured` (checked by `test_profit_blocked_before_maturation` and `test_profit_realized_after_maturation`). Someone who pushes the oracle to a false price cannot open a position and take its profit within those slots; by then the price has had that long to correct. A losing position can close in the slot it opened (`test_loss_not_gated_by_maturation`), and liquidation is never delayed. + +## Closing at $120, With the Pool Paying in Full -At $120 Alice takes her win. `close_position()` marks her position one last time: profit 5,000 × 20 ÷ 100 = 1,000 USDC, under her 5,000 cap; close fee 10 basis points of size = 5; payout 995 + 1,000 − 5 = **1,990 USDC**. That nearly doubles her stake on a 20% move, which is what 5x leverage means. Her 5,000 reservation releases, the LPs' `liquidity` drops by her 1,000 profit, `program_fees` collects the 5, her position account closes, and the vault pays her out under the pool's signature. The same handler protects the pool from the other direction. A position whose equity cannot cover its own close fee is refused with `PositionNotHealthy` and can only be closed by `liquidate_position()`, and any payout the pool arithmetic cannot cover is refused with `PoolInsolvent` rather than overdrawing the LPs. +At $120 Alice takes her win. Her position has been open far longer than ten slots. `close_position()` marks it one last time: profit 5,000 × 20 ÷ 100 = 1,000 USDC. Bob's position has been liquidated, so hers is the only one open, and the pool's 12,000-odd USDC of backing covers her 1,000 profit: `h` is 1. The close fee is 10 basis points of size, 5; her payout is 995 + 1,000 − 5 = **1,990 USDC**. That nearly doubles her stake on a 20% move, which is what 5x leverage means. The LPs' `liquidity` drops by her 1,000 profit, the fee splits between `program_fees` and `insurance_fund`, her position account closes, and the vault pays her out under the pool's signature. A position whose equity cannot cover its own close fee is refused with `PositionNotHealthy` and can only be closed by `liquidate_position()`. Here is where everyone ends up: - **Alice** turned 1,000 into 1,990 by being right with leverage. - **Bob** turned 1,000 into 195 by being wrong and slow. - **Dave** collected 50 USDC for one well-timed transaction. -- **Maria**'s `program_fees` holds 15, from two opens and one close (Bob's liquidation charges no close fee). She sweeps it with `collect_fees()`, which pays out what the fee ledger holds and sets it to zero. It is the only handler that needs her signature once the pool exists, and it refuses anyone else (`test_collect_fees_requires_authority`). +- **Maria**'s `program_fees` holds 7.50, half of the 15 USDC charged on two opens and one close (Bob's liquidation charges no close fee); the other 7.50 is in `insurance_fund`, where it stays. She sweeps it with `collect_fees()`, which pays out what the fee ledger holds and sets it to zero. It is the only handler that needs her signature once the pool exists, and it refuses anyone else (`test_collect_fees_requires_authority`). - **Carol** owns a twelfth of the pool, which absorbed Bob's 750 loss and paid Alice's 1,000 profit: down 250 on the trading, about 21 dollars of it hers. Her next twelfth comes from the traders who follow: their losses, less their profits, plus the net funding. ## The Math: One Formula per Concept, All Floored in the Pool's Favor @@ -147,27 +171,28 @@ Every calculation is checked u128 integer arithmetic, multiply before divide, an ## The Invariants: Percolator's Three, Simplified -The example's design notes point at Percolator, Anatoly Yakovenko's Kani-checked perp risk engine, and the three invariants it enforces are the ones to learn, because this program is a simplified working model of each: +The three invariants Percolator enforces are the ones to learn, because this program is a simplified working model of each: -- **Realizable credit**: every payout the system may ever owe is backed before it can be owed. Here: reservations at open, the profit cap, the free-liquidity check in `remove_liquidity()`, and the `PoolInsolvent` checks that refuse a payout the pool cannot cover. +- **Realizable credit**: a trader's collateral is senior and their profit is junior, paid only as far as it is backed. Here: the haircut, which never pays a winner more than `liquidity + insurance_fund`, the profit warm-up, and the cap on `remove_liquidity()` at `liquidity`. - **Account-local safety**: any position can be settled by touching that position and the pool, never a third account. Here: the entry-snapshot fields on `Position`. - **Bounded progress**: no operation's compute grows with the number of open positions. Here: the per-side accumulators and one-at-a-time liquidation. -The book's standing invariants hold here too. The vault balance always equals `liquidity + total_collateral + program_fees` plus dust; share value cannot be moved by donating tokens to the vault, because AUM reads tracked state, not balances; and every position opened above the initial margin can, at all times, be closed by either `close_position()` or `liquidate_position()`. +The book's standing invariants hold here too. The vault balance always equals `liquidity + total_collateral + program_fees + insurance_fund` plus dust, which the tests' `assert_vault_matches_ledger` checks. Share value cannot be moved by donating tokens to the vault, because AUM reads tracked state, not balances. Every position opened above the initial margin can, at all times, be closed by either `close_position()` or `liquidate_position()`. ## 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 five checks, the confidence cap, and the band around the pool's own average price. One trust decision stays visible: the pool creator chooses the feed, and the feed account's owner is deliberately unchecked. Whoever controls that feed sets the prices the pool settles at, held within the band and moving only as fast as the average follows. -- **The unliquidated-position gap**: liquidations that pay nothing do not happen, and the LPs absorb the loss. The example documents this gap, and it is why production venues add an insurance fund. +- **A position gapped through zero**: its deficit is paid from `insurance_fund` before it reaches the LPs, and anyone may liquidate it although the fee is forgiven (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 smaller than `MINIMUM_LIQUIDITY` is refused with `DepositTooSmall`. -- **Winning too big**: profit beyond the reservation is clipped by the cap rather than bankrupting the pool, and traders know the cap before they open. +- **Winning more than the pool holds**: every winner who closes is paid the same fraction of their profit rather than the first closers being paid in full and the rest nothing (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. - **Double close, double liquidation**: the position account closes when the position is closed or liquidated; the second call finds no account. - **Dangerous configurations**: parameter validation at pool creation, including the two cross-checks: the initial margin above the maintenance margin, and the maintenance margin above the close fee. ## Testing Considerations -The suite is the largest in the companion repository: a mock oracle the tests set prices on, and a `Market` helper that builds the pool, warps slots, and re-derives position PDAs. The pattern to copy is **one test per invariant, named as the sentence it checks**: `test_profit_capped_at_reserved_notional`, `test_remove_liquidity_blocked_by_reserved`, `test_healthy_position_cannot_be_liquidated`, `test_stale_price_rejected`, `test_collect_fees_requires_authority`. +The suite is the largest in the companion repository: a mock oracle the tests set prices on, and a `Market` helper that builds the pool, warps slots, and re-derives position PDAs. The pattern to copy is **one test per invariant, named as the sentence it checks**: `test_haircut_scales_profit_when_pool_stressed`, `test_remove_liquidity_capped_at_liquidity`, `test_healthy_position_cannot_be_liquidated`, `test_stale_price_rejected`, `test_collect_fees_requires_authority`. The band tests set a price, move the clock, and assert the average to the minor unit: `test_single_update_moves_average_by_elapsed_fraction` checks that one read moves nothing and the next moves the average by exactly the elapsed share of the window. @@ -175,11 +200,12 @@ The happy-path tests (`test_close_long_in_profit`, `test_close_long_in_loss`, `t ## What You Learnt from Building the Perpetual Futures Venue -A traditional futures exchange is a matching engine, a clearing house with a margin department, a funding desk, and a floor of liquidation clerks. One reservation does most of that work: every position reserves its maximum payout when it opens, so the pool can never owe more than it holds. The pool is the clearing house and the counterparty, the maintenance margin and the reservation are its margin department, the funding index is the funding desk, and a liquidator's bot is the liquidation clerk, paid per correction. +A traditional futures exchange is a matching engine, a clearing house with a margin department, a funding desk, and a floor of liquidation clerks. One rule does most of that work: collateral is senior and profit is junior, so the pool never pays out more than it holds. The pool is the clearing house and the counterparty, the two margins are its margin department, the insurance fund and the haircut are its default fund, the funding index is the funding desk, and a liquidator's bot is the liquidation clerk, paid per correction. -This chapter added six concepts: +This chapter added seven concepts: -- **Pre-funded solvency.** Reserve every position's maximum payout at open, cap profit at the reservation, and limit LP withdrawals to free liquidity, so the pool can never owe more than it holds. +- **Profit is a junior claim.** Pay each winner their profit times a haircut ratio, the pool's backing over what traders are owed, so every winner is paid the same fraction and the pool never pays out more than it holds. Delay profit by a warm-up so a brief false price cannot be cashed out. +- **An insurance fund before the LPs.** Route part of every fee into a fund that pays a bankrupt position's deficit first, and forgive a liquidation fee the position's equity cannot cover. - **Constant-time accounting.** Per-side scaled accumulators and entry snapshots make solvency, AUM, and settlement computable without ever iterating open positions: realizable credit, account-local safety, and bounded progress, in miniature. - **Two margins.** Require an initial margin to open a position and a lower maintenance margin to keep it, so a new position has room to move before it can be liquidated. Maximum leverage is one divided by the initial margin. - **A price band around the program's own average.** Keep a time-weighted average of the oracle's price, credit each interval to the price seen at its start, and refuse trades at prices too far from the average, while letting liquidations through. diff --git a/solana-building-blocks/time.md b/solana-building-blocks/time.md index 13addcdc..3ac2f1db 100644 --- a/solana-building-blocks/time.md +++ b/solana-building-blocks/time.md @@ -61,7 +61,7 @@ Two programs in this book accrue value over time, and both count it in seconds: ``` Elapsed seconds is the Clock's current timestamp minus the one the reserve stored at its last accrual, `last_accrual_timestamp`. Rather than recalculating every borrower's debt, the market stores each borrower's principal and updates the one shared factor, so a borrower's current debt is their principal multiplied by that factor. Its rates are quoted annually, so the conversion divides by the seconds in a year. The *Arithmetic of Money* chapter covers how to build that factor safely. -- **The perpetual futures venue accrues funding on open positions.** It does the same, storing `cumulative_funding` alongside `last_funding_timestamp`, and its operator sets the rate per second. +- **The perpetual futures venue accrues funding on open positions.** It does the same, storing `cumulative_funding` alongside `last_funding_timestamp`, at a rate per second fixed when the pool is created. Both accumulate, both advance by elapsed time, and both decide what somebody owes, which decides whether a position can be liquidated. A leader can move the timestamp by up to 800 milliseconds in its turn, and at a 20% annual rate even two seconds of interest is about one hundred-millionth of the debt. A position that close to the liquidation line crosses it anyway if the leader waits two seconds, so moving the clock gains the leader nothing. From 8338b6079f0b23272e9837302244cdc2893ed9c6 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 21:21:02 +0000 Subject: [PATCH 7/7] Perps figures: insurance_fund replaces reserved_liquidity; fees split Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- .../figures/perps-account-map.svg | 6 +++--- .../figures/perps-create-pool.svg | 6 +++--- .../figures/perps-liquidation.svg | 6 +++--- .../figures/perps-open-position.svg | 19 ++++++++++++++++++- .../perpetual-futures.md | 2 +- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/building-financial-products-in-solana/figures/perps-account-map.svg b/building-financial-products-in-solana/figures/perps-account-map.svg index cffa469b..f52e2ece 100644 --- a/building-financial-products-in-solana/figures/perps-account-map.svg +++ b/building-financial-products-in-solana/figures/perps-account-map.svg @@ -171,9 +171,9 @@ Pool liquidity: 12,000 USDC - reserved_liquidity: 10,000 - total_collateral: 1,990 - program_fees: 10 + total_collateral: 1,990 + program_fees: 5 + insurance_fund: 5 long_size: 5,000 short_size: 5,000 cumulative_funding: 0 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 dac19469..bcba7089 100644 --- a/building-financial-products-in-solana/figures/perps-create-pool.svg +++ b/building-financial-products-in-solana/figures/perps-create-pool.svg @@ -119,9 +119,9 @@ liquidity: 0 USDC NEW - reserved_liquidity: 0 - total_collateral: 0 - program_fees: 0 + total_collateral: 0 + program_fees: 0 + insurance_fund: 0 long_size: 0 short_size: 0 cumulative_funding: 0 diff --git a/building-financial-products-in-solana/figures/perps-liquidation.svg b/building-financial-products-in-solana/figures/perps-liquidation.svg index f303cc2d..4c3ed477 100644 --- a/building-financial-products-in-solana/figures/perps-liquidation.svg +++ b/building-financial-products-in-solana/figures/perps-liquidation.svg @@ -153,9 +153,9 @@ Pool liquidity: 12,750 USDC - reserved_liquidity: 5,000 - total_collateral: 995 - program_fees: 10 + total_collateral: 995 + program_fees: 5 + insurance_fund: 5 long_size: 5,000 short_size: 0 cumulative_funding: 0 diff --git a/building-financial-products-in-solana/figures/perps-open-position.svg b/building-financial-products-in-solana/figures/perps-open-position.svg index fd07906b..61f60a29 100644 --- a/building-financial-products-in-solana/figures/perps-open-position.svg +++ b/building-financial-products-in-solana/figures/perps-open-position.svg @@ -139,6 +139,22 @@ close_position() liquidate_position() collect_fees() + + + + "POOL" + COLLATERAL MINT + + ORACLE FEED ADDRESS + + + Pool + + liquidity: 12,000 USDC + total_collateral: 995 + program_fees: 2.50 + insurance_fund: 2.50 + long_size: 5,000 + short_size: 0 + cumulative_funding: 0 @@ -168,7 +184,8 @@ 1,000 USDC collateral in open_position() · side Long, 5,000 size - open fee: 10 bps of the 5,000 notional = 5 USDC, + open fee: 10 bps of the 5,000 notional = 5 USDC, + 2.50 to program_fees and 2.50 to insurance_fund, so 995 is her working collateral diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index 70e357ce..736dd9ca 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -6,7 +6,7 @@ The program lives at `finance/perpetual-futures` in the companion repository. It ## The Pool Is the Other Side of the Trade -In the *Order Book Exchange* chapter, every long met a short: Carol's ask crossed Bob's bid, and the program only recorded the result. A perp that trades against a pool has no orders to match. **Liquidity providers fund one pool, and the pool is the counterparty to every position.** When a trader wins, the pool pays; when a trader loses, the pool keeps the loss. So the LPs hold the opposite of the traders' net position: short NVDAx while more traders are long, long while more are short. An LP's return is the losing traders' losses minus the winning traders' profits, plus the net funding described below. The open and close fees go to the pool's creator, not to the LPs. +In the *Order Book Exchange* chapter, every long met a short: Carol's ask crossed Bob's bid, and the program only recorded the result. A perp that trades against a pool has no orders to match. **Liquidity providers fund one pool, and the pool is the counterparty to every position.** When a trader wins, the pool pays; when a trader loses, the pool keeps the loss. So the LPs hold the opposite of the traders' net position: short NVDAx while more traders are long, long while more are short. An LP's return is the losing traders' losses minus the winning traders' profits, plus the net funding described below. The open and close fees do not go to the LPs: half of each goes to the pool's creator and half to an insurance fund. That design decision creates the problem the program is organized around: the program must be able to check that the pool can pay every winner, at every moment, **without ever iterating over the open positions**. A program that must touch every position to answer "am I solvent?" stops fitting in a transaction once it has enough open positions. That requirement explains almost every field in the account model.