From fba2472b544afea2dc714e5a7a01ceb9d55f93b2 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 19:35:14 +0000 Subject: [PATCH 1/5] fundraiser: keep the fundraiser until its receipts close; drop the per-backer cap A successful claim closed the Fundraiser account while the Contributor accounts derived from its address stayed open. The maker could then initialize a new fundraiser at the same address, and a leftover Contributor account would count as a contribution to it: refund() would pay its old amount out of the new contributors' tokens. check_contributions() now pays out the vault and sets a claimed flag. The Fundraiser counts open Contributor accounts, and close_fundraiser() requires that count to be zero on both paths. refund() and close_contributor() no longer need the contributor's signature, so the maker can close every receipt without waiting on anyone. The per-contributor cap is removed: it limited wallets, and wallets cost nothing to create. The Kani cap harness is replaced with one that checks the open-account counter. Anchor v2 only; the Anchor v1 and Quasar ports follow. Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- finance/fundraiser/anchor/CHANGELOG.md | 17 + finance/fundraiser/anchor/README.md | 66 +- .../programs/fundraiser/src/constants.rs | 2 - .../anchor/programs/fundraiser/src/error.rs | 12 +- .../fundraiser/src/instructions/checker.rs | 43 +- .../fundraiser/src/instructions/close.rs | 69 +- .../src/instructions/close_contributor.rs | 48 +- .../fundraiser/src/instructions/contribute.rs | 37 +- .../src/instructions/initialize_fundraiser.rs | 2 + .../fundraiser/src/instructions/refund.rs | 11 +- .../anchor/programs/fundraiser/src/lib.rs | 2 +- .../fundraiser/src/state/fundraiser.rs | 8 + .../fundraiser/tests/test_fundraiser.rs | 1084 ++++++++--------- finance/fundraiser/kani-proofs/README.md | 13 +- finance/fundraiser/kani-proofs/src/lib.rs | 128 +- 15 files changed, 807 insertions(+), 735 deletions(-) diff --git a/finance/fundraiser/anchor/CHANGELOG.md b/finance/fundraiser/anchor/CHANGELOG.md index 6f0c1965a..a5da1b256 100644 --- a/finance/fundraiser/anchor/CHANGELOG.md +++ b/finance/fundraiser/anchor/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2026-10-01 + +### Fixed + +- **A contributor account could outlive its fundraiser and count toward the next one.** `check_contributions` closed the Fundraiser account while the Contributor accounts, derived from its address, stayed open. The maker could then initialize a new fundraiser at the same address, and a leftover Contributor account would count as a contribution to it: `refund` would pay its old amount out of the new contributors' tokens. `check_contributions` now pays out the vault and sets a new `claimed` flag instead of closing anything, and the Fundraiser keeps a new `open_contributor_accounts` count. `close_fundraiser` closes a claimed fundraiser once that count is zero (else the new `ContributorAccountsOpen` error), so no Contributor account survives into the next raise. `test_stale_contributor_account_cannot_refund_from_next_raise`, `test_reinitialize_with_open_contributor_accounts_fails` and `test_close_fundraiser_with_open_contributor_accounts_fails` cover it. + +### Changed + +- `close_contributor` requires the fundraiser to be claimed (`FundraiserNotClaimed`, replacing `FundraiserStillOpen`) rather than gone, and decrements `open_contributor_accounts`. +- `refund` and `close_contributor` no longer require the contributor's signature. The tokens and rent still go only to the contributor, and anyone can send either, so the maker can refund or close every Contributor account without waiting on any contributor. +- `contribute` and `check_contributions` refuse a claimed fundraiser with the new `FundraiserClaimed` error. +- Program errors are public (`pub use error::*`) so the tests assert each failure's specific error code. + +### Removed + +- The per-contributor cap (`MAX_CONTRIBUTION_PERCENTAGE`, `PERCENTAGE_SCALER`, and the `ContributionTooBig` and `MaximumContributionsReached` errors). It limited each wallet, and a wallet costs nothing to create, so it did not stop one person funding most of a raise. + ## 2026-09-28 - **Renamed from Token Fundraiser to Fundraiser.** The example moved from `finance/token-fundraiser` to `finance/fundraiser`: contributors receive no token, only a refund if the target is missed, so "Token" described something the program does not do. The program, its accounts, its instruction handlers and its tests are unchanged. diff --git a/finance/fundraiser/anchor/README.md b/finance/fundraiser/anchor/README.md index 31b463231..30bdf33de 100644 --- a/finance/fundraiser/anchor/README.md +++ b/finance/fundraiser/anchor/README.md @@ -6,7 +6,7 @@ > no prebuilt binary for this pre-release). The Anchor v1 version of this example is in > [`../anchor-v1`](../anchor-v1/). -Onchain crowdfunding on Solana: a program that collects tokens toward a target amount, like Kickstarter without a payment processor. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds and each contributor closes their own record to take back its rent; if it is not reached by the deadline, contributors can refund, and once refunds are complete the maker can retire the fundraiser and open a new one. +Onchain crowdfunding on Solana: a program that collects tokens toward a target amount, like Kickstarter without a payment processor. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds, anyone closes each contributor's record to return its rent to that contributor, and the maker then closes the fundraiser; if it is not reached by the deadline, anyone can refund each contributor, and once refunds are complete the maker closes the fundraiser. Either way, the maker can then open a new one. This example was called **Token Fundraiser** (`finance/token-fundraiser`) until it was renamed: contributors receive no token, only a refund if the target is missed. @@ -24,6 +24,8 @@ pub struct Fundraiser { pub current_amount: u64, pub time_started: i64, pub duration: u16, + pub claimed: bool, + pub open_contributor_accounts: u32, pub bump: u8, } ``` @@ -36,6 +38,8 @@ Fields: - `current_amount` - total amount contributed through the `contribute` handler. This tracked total, not the vault balance, is what `check_contributions` and `refund` compare against the target, so tokens sent directly to the vault cannot trigger an early release or block refunds. - `time_started` - when the fundraiser was created. - `duration` - fundraising window in days. +- `claimed` - set by `check_contributions`. A claimed fundraiser accepts no more contributions and no second claim. +- `open_contributor_accounts` - how many Contributor accounts written for this fundraiser are still open. `contribute` adds one when it creates a Contributor account; `refund` and `close_contributor` each subtract one when they close one. `close_fundraiser` requires zero. - `bump` - canonical bump for the Fundraiser [PDA](https://solana.com/docs/terminology#program-derived-address-pda). The `InitSpace` derive macro implements the `Space` trait, which calculates the size of the account (not counting the [Anchor](https://solana.com/docs/terminology#anchor) discriminator). @@ -55,7 +59,7 @@ pub struct Contributor { - `amount` - total amount contributed by this contributor. - `bump` - canonical bump for the Contributor PDA. -The Contributor PDA uses `init_if_needed`, which only runs the init branch on first call. The handler stores `bumps.contributor_account` into `bump` on first init (when `bump == 0`); see [`instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs). +The Contributor PDA uses `init_if_needed`, which only runs the init branch on first call. On first init (when `bump == 0`) the handler stores `bumps.contributor_account` into `bump` and adds one to `open_contributor_accounts`; see [`instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs). ### Constants @@ -64,11 +68,9 @@ From [`constants.rs`](programs/fundraiser/src/constants.rs): ```rust pub const MIN_AMOUNT_TO_RAISE: u64 = 3; pub const SECONDS_TO_DAYS: i64 = 86400; -pub const MAX_CONTRIBUTION_PERCENTAGE: u64 = 10; -pub const PERCENTAGE_SCALER: u64 = 100; ``` -`MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` = 10%, the per-contributor cap. `MIN_AMOUNT_TO_RAISE` is the minimum target in major units. +`MIN_AMOUNT_TO_RAISE` is the minimum target in major units. ### Code layout @@ -80,75 +82,75 @@ All token accounts use `anchor_spl::token_interface` types (`InterfaceAccount= MIN_AMOUNT_TO_RAISE * 10^decimals` (the target must be at least 3 major units of the mint, expressed in minor units), then initializes the Fundraiser state with `current_amount = 0` and `time_started` from the `Clock` sysvar. A target below the minimum fails with `InvalidAmount`. +The handler requires `amount >= MIN_AMOUNT_TO_RAISE * 10^decimals` (the target must be at least 3 major units of the mint, expressed in minor units), then initializes the Fundraiser state with `current_amount = 0`, `claimed = false`, `open_contributor_accounts = 0`, and `time_started` from the `Clock` sysvar. A target below the minimum fails with `InvalidAmount`. ### `contribute` [`programs/fundraiser/src/instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs), account constraints `ContributeAccountConstraints`. -A contributor signs and the handler performs four checks in order: +A contributor signs and the handler performs three checks in order: 1. Minimum contribution: `amount >= 10^decimals` (one major unit of the mint), else `ContributionTooSmall`. -2. Per-call cap: `amount <= amount_to_raise * MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` (10% of the target), else `ContributionTooBig`. +2. Not yet claimed: `!claimed`, else `FundraiserClaimed`. The deadline may still be days away after a claim, and the vault has already been paid out. 3. Time window: contributions are allowed while `elapsed_days < duration`, where `elapsed_days = (now - time_started) / SECONDS_TO_DAYS`. Once `elapsed_days` reaches `duration` the handler fails with `FundraiserEnded`. -4. Cumulative cap: the contributor's running total (existing + new) must not exceed the same 10% cap, else `MaximumContributionsReached`. -If all checks pass, `Fundraiser.current_amount` and `Contributor.amount` are updated, then `amount` is transferred from `contributor_ata` to `vault` with `transfer_checked`. +If all checks pass, `Fundraiser.current_amount` and `Contributor.amount` are updated (a contributor's later contributions add to the same Contributor account), then `amount` is transferred from `contributor_ata` to `vault` with `transfer_checked`. ### `check_contributions` [`programs/fundraiser/src/instructions/checker.rs`](programs/fundraiser/src/instructions/checker.rs), account constraints `CheckContributionsAccountConstraints`. -Lets the maker claim the funds once the target is met. Requires `fundraiser.current_amount >= amount_to_raise` (the state-tracked total, so direct donations to the vault cannot unlock the claim early), else `TargetNotMet`. The handler then, signing both CPIs with the Fundraiser PDA's seeds: +Lets the maker claim the funds once the target is met. Requires `!claimed`, else `FundraiserClaimed`, and `fundraiser.current_amount >= amount_to_raise` (the state-tracked total, so direct donations to the vault cannot unlock the claim early), else `TargetNotMet`. The handler sets `claimed` and transfers the entire vault balance (including any direct donations) to `maker_ata` with `transfer_checked`, signed with the Fundraiser PDA's seeds. -1. Transfers the entire vault balance (including any direct donations) to `maker_ata` with `transfer_checked`. -2. Closes the empty vault token account with `close_account`, returning its rent to the maker. - -The Fundraiser state account is closed via the `close = maker` constraint, so the maker also recovers that [rent](https://solana.com/docs/terminology#rent). +The Fundraiser account and the empty vault stay open. Contributor accounts are derived from the Fundraiser's address, so the Fundraiser must outlive every one of them: if it closed here, the maker could initialize a new fundraiser at the same address, and the Contributor accounts left over from this raise would count as contributions to the new one, so `refund` would pay their old amounts out of the new contributors' tokens. `close_contributor` closes the Contributor accounts, then `close_fundraiser` closes the Fundraiser and the vault. ### `refund` [`programs/fundraiser/src/instructions/refund.rs`](programs/fundraiser/src/instructions/refund.rs), account constraints `RefundAccountConstraints`. -Lets a contributor reclaim their contribution after a failed fundraiser. Two checks: +Returns a contribution after a failed fundraiser. The contributor does not have to sign: the tokens go to their token account and the rent to them, whoever sends the transaction, so the maker can refund every contributor and close a failed fundraiser without waiting on any of them. Two checks: 1. Refunds are allowed only after the fundraiser has ended: `elapsed_days >= duration`, else `FundraiserNotEnded`. 2. The target was not met: `fundraiser.current_amount < amount_to_raise` (again the state-tracked total, so donated tokens cannot block refunds), else `TargetMet`. -The handler subtracts the contributor's recorded amount from `current_amount` and zeroes the Contributor record before the transfer CPI, then sends the tokens from the vault back to `contributor_ata` with `transfer_checked` (PDA signer). The Contributor account is closed via `close = contributor`, refunding its rent to the contributor. +The handler subtracts the contributor's recorded amount from `current_amount` zeroes the Contributor record, and subtracts one from `open_contributor_accounts` before the transfer CPI, then sends the tokens from the vault back to `contributor_ata` with `transfer_checked` (PDA signer). The Contributor account is closed via `close = contributor`, refunding its rent to the contributor. ### `close_fundraiser` [`programs/fundraiser/src/instructions/close.rs`](programs/fundraiser/src/instructions/close.rs), account constraints `CloseFundraiserAccountConstraints`. -Retires a failed fundraiser so the maker can raise again. The Fundraiser PDA is derived from `b"fundraiser"` and the maker's public key alone, so while a failed fundraiser's account exists the maker can never initialize another one. Three checks: +Closes a finished fundraiser and its vault so the maker can raise again. The Fundraiser PDA is derived from `b"fundraiser"` and the maker's public key alone, so while a Fundraiser account exists the maker cannot initialize another one. + +For an unclaimed fundraiser, three checks: 1. The fundraiser has ended: `elapsed_days >= duration`, else `FundraiserNotEnded`. -2. The target was not met: `fundraiser.current_amount < amount_to_raise`, else `TargetMet` (a successful raise exits through `check_contributions`, which already closes these accounts). +2. The target was not met: `fundraiser.current_amount < amount_to_raise`, else `TargetMet` (a raise that met its target closes after the maker claims it). 3. Every contribution has been refunded: `fundraiser.current_amount == 0`, else `RefundsOutstanding` (closing the vault earlier would strand the remaining refunds). -Anything still in the vault at this point is a direct donation outside the program's accounting; the handler sweeps it to `maker_ata` with `transfer_checked` rather than burning it, then closes the vault with `close_account` (both CPIs signed with the Fundraiser PDA's seeds). The Fundraiser state account is closed via `close = maker`. +For every fundraiser, claimed or not: `open_contributor_accounts == 0`, else `ContributorAccountsOpen`. A Contributor account left open would be read as a contribution to the next fundraiser at this address. + +Anything still in the vault at this point is a direct donation outside the program's accounting; the handler pays it to `maker_ata` with `transfer_checked` rather than burning it, then closes the vault with `close_account` (both CPIs signed with the Fundraiser PDA's seeds). The Fundraiser state account is closed via `close = maker`. ### `close_contributor` [`programs/fundraiser/src/instructions/close_contributor.rs`](programs/fundraiser/src/instructions/close_contributor.rs), account constraints `CloseContributorAccountConstraints`. -Lets a contributor close their Contributor account once the fundraiser is gone, taking back its rent. A successful raise exits through `check_contributions`, which closes the vault and the Fundraiser account but cannot reach the Contributor accounts: there is one per contributor and the claim carries none of them. Their other closer, `refund`, runs only on a failed raise, so without this handler every contributor to a successful raise would hold their rent in an account nothing could close. +Closes a Contributor account once its fundraiser has been claimed, returning the rent to the contributor. On a successful raise the contribution has been paid out to the maker, so the account holds only rent, and `close_fundraiser` cannot run until every one of them is closed. -One check: the `fundraiser` account passed in is not owned by this program, else `FundraiserStillOpen`. A live fundraiser is program-owned; a closed one belongs to the system program again, whatever lamports it holds. The Contributor account's seeds bind it to that fundraiser address, so no other fundraiser can be substituted. The account is closed via `close = contributor`. +One check: `fundraiser.claimed`, else `FundraiserNotClaimed`. While the fundraiser is unclaimed the contribution can still be refunded, so `refund` is the way to close it. The contributor does not have to sign: the rent goes to them whoever sends the transaction, so the maker can close every Contributor account and then the fundraiser. The Contributor account's seeds bind it to the fundraiser's address, and the handler subtracts one from `open_contributor_accounts`. The account is closed via `close = contributor`. ## Testing @@ -159,22 +161,28 @@ cargo build-sbf cargo test ``` -The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. It exercises both contribution caps (a single contribution over the 10% cap, and contributions that cumulatively exceed it), and verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, that `close_fundraiser` retires a failed raise (only after the deadline, only when the target was missed, only once refunds are complete, sweeping direct donations to the maker) and lets the same maker initialize a fresh fundraiser, and that `close_contributor` returns a contributor's rent after a successful claim and is refused while the fundraiser exists. Assertions check token balances and decoded account state rather than just transaction success. +The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. Every failing case asserts the specific program error. + +It checks that the claim pays the maker and marks the fundraiser claimed, that a second claim and a contribution after the claim are refused, that direct vault donations do not unlock the claim, and that anyone can refund a contributor or close their Contributor account after a claim, with the tokens and rent going to the contributor. + +`test_stale_contributor_account_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contributor accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails while every second-raise contributor gets back exactly what they put in. `test_reinitialize_with_open_contributor_accounts_fails` and `test_close_fundraiser_with_open_contributor_accounts_fails` check that the second raise cannot start while any first-raise Contributor account is open. + +`close_fundraiser` is tested on both paths (after a failed raise, only after the deadline, only when the target was missed and refunds are complete; after a claim, only once every Contributor account is closed), including that it pays direct donations to the maker and that the same maker can then initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success. ## FAQ ### How do I build crowdfunding on Solana? -A maker opens a fundraiser with `initialize_fundraiser`, naming the token, target amount, and duration. Contributors deposit with `contribute` while the window is open, and the funds sit in a program-controlled vault that neither side can raid. When the target is reached, the maker claims the raise with `check_contributions`, which pays out the vault and closes the fundraiser. +A maker opens a fundraiser with `initialize_fundraiser`, naming the token, target amount, and duration. Contributors deposit with `contribute` while the window is open, and the funds sit in a program-controlled vault that neither side can raid. When the target is reached, the maker claims the raise with `check_contributions`, which pays out the vault and marks the fundraiser claimed. ### What happens to the contributor accounts after a successful raise? -The claim closes the vault and the Fundraiser account, but each Contributor account stays open with its rent inside. Its owner calls `close_contributor`, which checks that the fundraiser is gone and returns the rent. +Each stays open with its rent inside until someone calls `close_contributor`, which checks that the fundraiser has been claimed and returns the rent to the contributor. Anyone can send it, so the maker can close them all, then call `close_fundraiser` to close the Fundraiser account and the vault. ### What happens if the fundraiser misses its target? -Contributors call `refund` after the deadline to reclaim exactly what they put in. Once refunds are complete, the maker calls `close_fundraiser` to retire the failed raise and can then open a new one. +After the deadline, `refund` returns each contributor exactly what they put in; anyone can send it. Once refunds are complete, the maker calls `close_fundraiser` to retire the failed raise and can then open a new one. ### How is this fundraiser tested and verified? -`anchor build` then `cargo test` runs LiteSVM tests that warp the clock across the deadline to exercise contribution windows, per-contributor caps, claims, refunds, and closing. The arithmetic has [Kani](https://github.com/model-checking/kani) model checks in [`../kani-proofs/`](../kani-proofs/). +`anchor build` then `cargo test` runs LiteSVM tests that warp the clock across the deadline to exercise contribution windows, claims, refunds, and closing on both paths. The arithmetic has [Kani](https://github.com/model-checking/kani) model checks in [`../kani-proofs/`](../kani-proofs/). diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/constants.rs b/finance/fundraiser/anchor/programs/fundraiser/src/constants.rs index 2af19dd91..87e10ebc5 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/constants.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/constants.rs @@ -1,4 +1,2 @@ pub const MIN_AMOUNT_TO_RAISE: u64 = 3; pub const SECONDS_TO_DAYS: i64 = 86400; -pub const MAX_CONTRIBUTION_PERCENTAGE: u64 = 10; -pub const PERCENTAGE_SCALER: u64 = 100; diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/error.rs b/finance/fundraiser/anchor/programs/fundraiser/src/error.rs index 06e94d8a1..f386accc6 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/error.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/error.rs @@ -6,12 +6,8 @@ pub enum FundraiserError { TargetNotMet, #[msg("The amount to raise has been achieved")] TargetMet, - #[msg("The contribution is too big")] - ContributionTooBig, #[msg("The contribution is too small")] ContributionTooSmall, - #[msg("The maximum amount to contribute has been reached")] - MaximumContributionsReached, #[msg("The fundraiser has not ended yet")] FundraiserNotEnded, #[msg("The fundraiser has ended")] @@ -22,6 +18,10 @@ pub enum FundraiserError { RefundsOutstanding, #[msg("Arithmetic overflow")] MathOverflow, - #[msg("The fundraiser still exists, so the contributor account closes through refund")] - FundraiserStillOpen, + #[msg("The fundraiser has already been claimed")] + FundraiserClaimed, + #[msg("The fundraiser has not been claimed, so the contributor account closes through refund")] + FundraiserNotClaimed, + #[msg("Contributor accounts for this fundraiser are still open, so it cannot close yet")] + ContributorAccountsOpen, } diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs index 16b5916bd..0d58eabc4 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs @@ -1,10 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token_interface::{ - close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, - TransferChecked, - }, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, }; use crate::{state::Fundraiser, FundraiserError}; @@ -20,7 +17,6 @@ pub struct CheckContributionsAccountConstraints { mut, seeds = [b"fundraiser".as_ref(), maker.address().as_ref()], bump = fundraiser.bump, - close = maker, )] pub fundraiser: BorshAccount, @@ -48,9 +44,23 @@ pub struct CheckContributionsAccountConstraints { pub associated_token_program: Program, } +/// Pays the vault out to the maker once the target is met, and marks the +/// fundraiser claimed. +/// +/// The fundraiser account and the vault stay open: contributor accounts are +/// derived from the fundraiser's address, so the fundraiser must outlive every +/// one of them. Otherwise the maker could initialize a new fundraiser at the +/// same address, and contributor accounts left over from this raise would +/// count as contributions to the new one. `close_contributor` closes them, +/// then `close_fundraiser` closes the fundraiser and the vault. pub fn handle_check_contributions( accounts: &mut CheckContributionsAccountConstraints, ) -> Result<()> { + require!( + !accounts.fundraiser.claimed, + FundraiserError::FundraiserClaimed + ); + // Compare the state-tracked total, not the vault balance, so tokens // donated directly to the vault cannot trigger an early release. require!( @@ -58,27 +68,29 @@ pub fn handle_check_contributions( FundraiserError::TargetNotMet ); + accounts.fundraiser.claimed = true; + // Read these before any of the CPI handles below take their borrows. let maker_address = *accounts.maker.address(); let vault_amount = accounts.vault.amount(); let mint_decimals = accounts.mint_to_raise.decimals(); - // `fundraiser` signs both CPIs below. It is a data account holding a live + // `fundraiser` signs the CPI below. It is a data account holding a live // borrow on its buffer, so release it across the CPIs. The runtime rejects // a CPI that borrows an account we still hold. Take it back after. let fundraiser_bump = accounts.fundraiser.bump; accounts.fundraiser.release_borrow()?; let fundraiser_view = *accounts.fundraiser.account(); - // The vault is owned by the fundraiser PDA, so both CPIs are signed with - // its seeds. + // The vault is owned by the fundraiser PDA, so the CPI is signed with its + // seeds. let signer_seeds: [&[&[u8]]; 1] = [&[ b"fundraiser".as_ref(), maker_address.as_ref(), &[fundraiser_bump], ]]; - // Drain the whole vault (including any direct donations) to the maker. + // Pay the whole vault (including any direct donations) to the maker. let transfer_accounts = TransferChecked { from: accounts.vault.cpi_handle_mut(), mint: accounts.mint_to_raise.cpi_handle(), @@ -92,19 +104,6 @@ pub fn handle_check_contributions( ); transfer_checked(transfer_context, vault_amount, mint_decimals)?; - // Close the empty vault so its rent goes back to the maker. - let close_accounts = CloseAccount { - account: accounts.vault.cpi_handle_mut(), - destination: accounts.maker.cpi_handle_mut(), - authority: CpiHandle::readonly(&fundraiser_view), - }; - let close_context = CpiContext::new_with_signer( - accounts.token_program.address(), - close_accounts, - &signer_seeds, - ); - close_account(close_context)?; - // Take the borrow back before the derive's exit path touches it again. accounts.fundraiser.reacquire_borrow_mut()?; diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close.rs index 4873bf3b7..cf7b0ab97 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close.rs @@ -49,38 +49,47 @@ pub struct CloseFundraiserAccountConstraints { pub associated_token_program: Program, } -/// Retires a failed fundraiser so the maker can raise again. +/// Closes a finished fundraiser and its vault so the maker can raise again. /// -/// The fundraiser PDA is derived from the maker's key alone, so while a -/// failed fundraiser's account exists the maker can never initialize -/// another one. This handler closes it once the deadline has passed, the -/// target was missed, and every contribution has been refunded. +/// The fundraiser PDA is derived from the maker's public key alone, so while +/// a fundraiser account exists the maker cannot initialize another one. It +/// closes once no contributor account written for it is still open: after a +/// claim, once `close_contributor` has closed each one; after a failed raise, +/// once the deadline has passed and `refund` has closed each one. pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints) -> Result<()> { - // Closing is allowed only after the fundraiser has ended: - // elapsed_days >= duration. - let current_time = Clock::get()?.unix_timestamp; - let elapsed_days = current_time - .checked_sub(accounts.fundraiser.time_started) - .ok_or(FundraiserError::MathOverflow)? - .checked_div(SECONDS_TO_DAYS) - .ok_or(FundraiserError::MathOverflow)?; - require!( - elapsed_days >= accounts.fundraiser.duration as i64, - FundraiserError::FundraiserNotEnded - ); + if !accounts.fundraiser.claimed { + // Closing an unclaimed fundraiser is allowed only after it has ended: + // elapsed_days >= duration. + let current_time = Clock::get()?.unix_timestamp; + let elapsed_days = current_time + .checked_sub(accounts.fundraiser.time_started) + .ok_or(FundraiserError::MathOverflow)? + .checked_div(SECONDS_TO_DAYS) + .ok_or(FundraiserError::MathOverflow)?; + require!( + elapsed_days >= accounts.fundraiser.duration as i64, + FundraiserError::FundraiserNotEnded + ); - // A successful fundraiser exits through check_contributions, which - // already closes these accounts. - require!( - accounts.fundraiser.current_amount < accounts.fundraiser.amount_to_raise, - FundraiserError::TargetMet - ); + // A raise that met its target closes after the maker claims it. + require!( + accounts.fundraiser.current_amount < accounts.fundraiser.amount_to_raise, + FundraiserError::TargetMet + ); + + // Closing the vault while contributions remain would strand the + // refunds, so every contributor must have been refunded first. + require!( + accounts.fundraiser.current_amount == 0, + FundraiserError::RefundsOutstanding + ); + } - // Closing the vault while contributions remain would strand the - // refunds, so every contributor must have taken theirs first. + // A contributor account left open would be read as a contribution to the + // next fundraiser at this address. require!( - accounts.fundraiser.current_amount == 0, - FundraiserError::RefundsOutstanding + accounts.fundraiser.open_contributor_accounts == 0, + FundraiserError::ContributorAccountsOpen ); // Read these before any of the CPI handles below take their borrows. @@ -103,9 +112,9 @@ pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints) &[fundraiser_bump], ]]; - // Refunds have already drained every tracked contribution, so anything - // left in the vault is a direct donation; sweep it to the maker rather - // than burn it with the account. + // The claim or the refunds have already paid out every tracked + // contribution, so anything left in the vault is a direct donation; pay + // it to the maker rather than burn it with the account. if accounts.vault.amount() > 0 { let transfer_accounts = TransferChecked { from: accounts.vault.cpi_handle_mut(), diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs index 944f3fb35..20121d869 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs @@ -1,21 +1,23 @@ use anchor_lang::prelude::*; -use crate::{state::Contributor, FundraiserError}; +use crate::{ + state::{Contributor, Fundraiser}, + FundraiserError, +}; #[derive(Accounts)] pub struct CloseContributorAccountConstraints { + /// Not a signer: the rent goes to the contributor, whoever sends the + /// transaction. So a maker can close every contributor account and then + /// the fundraiser without waiting on any contributor. #[account(mut)] - pub contributor: Signer, + pub contributor: SystemAccount, - /// CHECK: the fundraiser this contributor account was written for. The - /// contributor account's seeds bind it to this address, so no other - /// fundraiser can be substituted. The constraint requires the account to - /// be gone: a live fundraiser is owned by this program, and a closed one - /// belongs to the system program again, whatever lamports it holds. #[account( - constraint = !fundraiser.account().owned_by(&crate::ID) @ FundraiserError::FundraiserStillOpen, + mut, + constraint = fundraiser.claimed @ FundraiserError::FundraiserNotClaimed, )] - pub fundraiser: UncheckedAccount, + pub fundraiser: BorshAccount, #[account( mut, @@ -26,20 +28,20 @@ pub struct CloseContributorAccountConstraints { pub contributor_account: BorshAccount, } -/// Closes a contributor account once its fundraiser is gone, returning the -/// rent to the contributor. -/// -/// A successful raise exits through `check_contributions`, which closes the -/// vault and the fundraiser but cannot reach the contributor accounts: there -/// is one per contributor and the claim carries none of them. Their other -/// closer, `refund`, runs only on a failed raise. Without this handler every -/// contributor to a successful raise would hold their rent in an account -/// nothing could close. +/// Closes a contributor account once its fundraiser has been claimed, +/// returning the rent to the contributor. /// -/// The one check is that the fundraiser account no longer exists, which is -/// the `constraint` above; the `close = contributor` constraint then returns -/// the rent. While the fundraiser exists the contribution is live, and -/// `refund` is the way to close it. -pub fn handle_close_contributor(_accounts: &mut CloseContributorAccountConstraints) -> Result<()> { +/// `refund` closes contributor accounts on a failed raise. On a successful +/// one the contribution has been paid out to the maker, so the account only +/// holds rent, and `close_fundraiser` cannot run until every one of them is +/// closed. While the fundraiser is unclaimed the contribution can still be +/// refunded, so this handler refuses with `FundraiserNotClaimed`. +pub fn handle_close_contributor(accounts: &mut CloseContributorAccountConstraints) -> Result<()> { + accounts.fundraiser.open_contributor_accounts = accounts + .fundraiser + .open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?; + Ok(()) } diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs index d6b7e306f..6713235d0 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs @@ -5,7 +5,7 @@ use anchor_spl::token_interface::{ use crate::{ state::{Contributor, Fundraiser}, - FundraiserError, MAX_CONTRIBUTION_PERCENTAGE, PERCENTAGE_SCALER, SECONDS_TO_DAYS, + FundraiserError, SECONDS_TO_DAYS, }; #[derive(Accounts)] @@ -53,18 +53,6 @@ pub struct ContributeAccountConstraints { pub system_program: Program, } -/// Caps a single contributor at MAX_CONTRIBUTION_PERCENTAGE percent of the -/// target. Multiplies in u128 so the product cannot overflow u64. -fn calculate_max_contribution(amount_to_raise: u64) -> Result { - (amount_to_raise as u128) - .checked_mul(MAX_CONTRIBUTION_PERCENTAGE as u128) - .ok_or(FundraiserError::MathOverflow)? - .checked_div(PERCENTAGE_SCALER as u128) - .ok_or(FundraiserError::MathOverflow)? - .try_into() - .map_err(|_| FundraiserError::MathOverflow.into()) -} - pub fn handle_contribute( accounts: &mut ContributeAccountConstraints, amount: u64, @@ -79,13 +67,13 @@ pub fn handle_contribute( FundraiserError::ContributionTooSmall ); - let max_contribution = calculate_max_contribution(accounts.fundraiser.amount_to_raise)?; + // A claimed fundraiser has paid its vault out to the maker, so a later + // contribution would go to the maker with no refund path. require!( - amount <= max_contribution, - FundraiserError::ContributionTooBig + !accounts.fundraiser.claimed, + FundraiserError::FundraiserClaimed ); - // Contributions are allowed while elapsed_days < duration. let current_time = Clock::get()?.unix_timestamp; let elapsed_days = current_time .checked_sub(accounts.fundraiser.time_started) @@ -97,16 +85,11 @@ pub fn handle_contribute( FundraiserError::FundraiserEnded ); - // The contributor's cumulative total must also stay within the cap. let cumulative_contribution = accounts .contributor_account .amount .checked_add(amount) .ok_or(FundraiserError::MathOverflow)?; - require!( - cumulative_contribution <= max_contribution, - FundraiserError::MaximumContributionsReached - ); // Checks-effects-interactions: update state before the transfer CPI. accounts.fundraiser.current_amount = accounts @@ -116,10 +99,16 @@ pub fn handle_contribute( .ok_or(FundraiserError::MathOverflow)?; accounts.contributor_account.amount = cumulative_contribution; - // Save the contributor PDA bump on first init (init_if_needed only - // runs the init branch once; stored bump is zero until set). + // On first init (init_if_needed only runs the init branch once; the + // stored bump is zero until set), save the contributor PDA bump and count + // the new contributor account against the fundraiser. if accounts.contributor_account.bump == 0 { accounts.contributor_account.bump = bumps.contributor_account; + accounts.fundraiser.open_contributor_accounts = accounts + .fundraiser + .open_contributor_accounts + .checked_add(1) + .ok_or(FundraiserError::MathOverflow)?; } // Transfer the funds from the contributor to the vault. diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/initialize_fundraiser.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/initialize_fundraiser.rs index 0f7adf7ce..17a35dae9 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/initialize_fundraiser.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/initialize_fundraiser.rs @@ -64,6 +64,8 @@ pub fn handle_initialize_fundraiser( current_amount: 0, time_started: Clock::get()?.unix_timestamp, duration, + claimed: false, + open_contributor_accounts: 0, bump: bumps.fundraiser, }; diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs index 7e54c7a00..42f4150a6 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs @@ -10,8 +10,12 @@ use crate::{ #[derive(Accounts)] pub struct RefundAccountConstraints { + /// Not a signer: the tokens go to the contributor's token account and the + /// rent to the contributor, whoever sends the transaction. So a maker can + /// refund every contributor and close a failed fundraiser without waiting + /// on any of them. #[account(mut)] - pub contributor: Signer, + pub contributor: SystemAccount, pub maker: SystemAccount, @@ -84,6 +88,11 @@ pub fn handle_refund(accounts: &mut RefundAccountConstraints) -> Result<()> { .checked_sub(refund_amount) .ok_or(FundraiserError::MathOverflow)?; accounts.contributor_account.amount = 0; + accounts.fundraiser.open_contributor_accounts = accounts + .fundraiser + .open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?; // Read these before any CPI handle below takes its borrow. `maker` is a // read-only account here, so asking it for a writable handle would panic. diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/lib.rs b/finance/fundraiser/anchor/programs/fundraiser/src/lib.rs index 10bbe3c58..bd0c8c5c4 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/lib.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/lib.rs @@ -8,7 +8,7 @@ mod instructions; mod state; pub use constants::*; -use error::*; +pub use error::*; use instructions::*; #[program] diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/state/fundraiser.rs b/finance/fundraiser/anchor/programs/fundraiser/src/state/fundraiser.rs index 12ba8bc72..34e818392 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/state/fundraiser.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/state/fundraiser.rs @@ -9,5 +9,13 @@ pub struct Fundraiser { pub current_amount: u64, pub time_started: i64, pub duration: u16, + /// Set by `check_contributions`. A claimed fundraiser accepts no more + /// contributions and no second claim, and stays open until every + /// contributor account written for it has been closed. + pub claimed: bool, + /// How many contributor accounts written for this fundraiser are still + /// open. `close_fundraiser` requires zero, so a new fundraiser at the + /// same address never starts with contributor accounts from an old one. + pub open_contributor_accounts: u32, pub bump: u8, } diff --git a/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs b/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs index 60d94dd35..f9fd00e0e 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs @@ -5,7 +5,7 @@ use { }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, borsh::BorshDeserialize, - fundraiser::SECONDS_TO_DAYS, + fundraiser::{FundraiserError, SECONDS_TO_DAYS}, // LiteSVM's get_sysvar wants the host-side Clock, not pinocchio's. solana_clock::Clock, solana_kite::{ @@ -20,10 +20,14 @@ const MINT_DECIMALS: u8 = 6; const ONE_TOKEN: u64 = 1_000_000; /// Comfortably above the program's 3-major-unit minimum target. const AMOUNT_TO_RAISE: u64 = 30 * ONE_TOKEN; -/// The per-contributor cap is 10% of the target. -const MAX_CONTRIBUTION: u64 = AMOUNT_TO_RAISE / 10; +/// Three unequal contributions that together reach the target exactly. +const CONTRIBUTIONS_REACHING_TARGET: [u64; 3] = [12 * ONE_TOKEN, 10 * ONE_TOKEN, 8 * ONE_TOKEN]; +/// A contribution well short of the target on its own. +const CONTRIBUTION: u64 = 4 * ONE_TOKEN; const DURATION_DAYS: u16 = 7; -const CONTRIBUTOR_STARTING_BALANCE: u64 = 10 * ONE_TOKEN; +const CONTRIBUTOR_STARTING_BALANCE: u64 = 20 * ONE_TOKEN; +/// LiteSVM's fee for a transaction with one signature. +const TRANSACTION_FEE: u64 = 5_000; fn token_program_id() -> Address { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -55,6 +59,8 @@ struct FundraiserState { current_amount: u64, _time_started: i64, duration: u16, + claimed: bool, + open_contributor_accounts: u32, _bump: u8, } @@ -286,6 +292,154 @@ fn build_close_fundraiser_instruction(setup: &FundraiserSetup, maker_ata: &Addre ) } +struct FundedContributor { + keypair: Keypair, + ata: Address, + contributor_account_pda: Address, +} + +/// Sends `contribute` for the given contributor and amount, signed by the +/// contributor. +fn contribute( + setup: &mut FundraiserSetup, + contributor: &FundedContributor, + amount: u64, +) -> Result<(), String> { + let contribute_instruction = build_contribute_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.ata, + &contributor.contributor_account_pda, + amount, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor.keypair], + &contributor.keypair.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +fn new_contributor(setup: &mut FundraiserSetup) -> FundedContributor { + let (keypair, ata, contributor_account_pda) = create_funded_contributor(setup); + FundedContributor { + keypair, + ata, + contributor_account_pda, + } +} + +/// Creates three contributors whose contributions reach the target exactly. +fn fund_to_target(setup: &mut FundraiserSetup) -> Vec { + CONTRIBUTIONS_REACHING_TARGET + .iter() + .map(|amount| { + let contributor = new_contributor(setup); + contribute(setup, &contributor, *amount).unwrap(); + contributor + }) + .collect() +} + +/// Sends `refund` for `contributor`, paid for by `fee_payer`, who need not be the contributor. +fn refund( + setup: &mut FundraiserSetup, + fee_payer: &Keypair, + contributor: &FundedContributor, +) -> Result<(), String> { + let refund_instruction = build_refund_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.ata, + &contributor.contributor_account_pda, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![refund_instruction], + &[fee_payer], + &fee_payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `check_contributions`, signed by the maker. +fn claim(setup: &mut FundraiserSetup) -> Result { + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let check_instruction = build_check_contributions_instruction(setup, &maker_ata); + let maker = setup.maker.insecure_clone(); + send_transaction_from_instructions( + &mut setup.svm, + vec![check_instruction], + &[&maker], + &maker.pubkey(), + ) + .map(|_| maker_ata) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `close_contributor` for `contributor`, signed and paid for by +/// `fee_payer`. +fn close_contributor( + setup: &mut FundraiserSetup, + fee_payer: &Keypair, + contributor: &FundedContributor, +) -> Result<(), String> { + let close_instruction = build_close_contributor_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.contributor_account_pda, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[fee_payer], + &fee_payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `close_fundraiser`, signed by the maker. +fn close_fundraiser(setup: &mut FundraiserSetup) -> Result<(), String> { + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let close_instruction = build_close_fundraiser_instruction(setup, &maker_ata); + let maker = setup.maker.insecure_clone(); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[&maker], + &maker.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +fn lamports(setup: &FundraiserSetup, address: &Address) -> u64 { + setup + .svm + .get_account(address) + .map_or(0, |account| account.lamports) +} + +/// Anchor numbers a program's `#[error_code]` variants from 6000. +const ANCHOR_ERROR_CODE_OFFSET: u32 = 6000; + +/// Asserts that a transaction failed with the given program error. +fn assert_error(result: Result, expected_error: FundraiserError) { + let error = result.expect_err("transaction should have failed"); + let expected_code = format!( + "Custom({})", + ANCHOR_ERROR_CODE_OFFSET + expected_error as u32 + ); + assert!( + error.contains(&expected_code), + "expected {expected_code}, got: {error}" + ); +} + #[test] fn test_initialize_fundraiser() { let mut setup = full_setup(); @@ -296,6 +450,8 @@ fn test_initialize_fundraiser() { assert_eq!(fundraiser_state.amount_to_raise, AMOUNT_TO_RAISE); assert_eq!(fundraiser_state.current_amount, 0); assert_eq!(fundraiser_state.duration, DURATION_DAYS); + assert!(!fundraiser_state.claimed); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -332,11 +488,9 @@ fn test_initialize_below_minimum_target_fails() { vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Target below 3 major units must be rejected" - ); + ) + .map_err(|error| format!("{error:?}")); + assert_error(result, FundraiserError::InvalidAmount); assert!( setup.svm.get_account(&setup.fundraiser_pda).is_none(), "Fundraiser account must not exist after a failed initialize" @@ -347,76 +501,78 @@ fn test_initialize_below_minimum_target_fails() { fn test_contribute_inside_window_succeeds() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); + let contributor = new_contributor(&mut setup); // One day in: well inside the 7-day window. warp_days_forward(&mut setup.svm, 1); - - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - MAX_CONTRIBUTION + CONTRIBUTION ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), - CONTRIBUTOR_STARTING_BALANCE - MAX_CONTRIBUTION + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE - CONTRIBUTION ); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); - assert_eq!(fundraiser_state.current_amount, MAX_CONTRIBUTION); + assert_eq!(fundraiser_state.current_amount, CONTRIBUTION); + assert_eq!(fundraiser_state.open_contributor_accounts, 1); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!(contributor_state.amount, CONTRIBUTION); } #[test] -fn test_contribute_after_deadline_fails() { +fn test_contributions_accumulate_in_one_contributor_account() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // One day past the deadline. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + let first_contribution = 5 * ONE_TOKEN; + let second_contribution = 3 * ONE_TOKEN; + contribute(&mut setup, &contributor, first_contribution).unwrap(); + warp_days_forward(&mut setup.svm, 1); + contribute(&mut setup, &contributor, second_contribution).unwrap(); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN, + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!( + contributor_state.amount, + first_contribution + second_contribution ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), + + // A second contribution adds to the existing account rather than + // creating another, so the fundraiser still counts one. + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!( + fundraiser_state.current_amount, + first_contribution + second_contribution ); - assert!(result.is_err(), "Contributing after the deadline must fail"); + assert_eq!(fundraiser_state.open_contributor_accounts, 1); +} + +#[test] +fn test_contribute_after_deadline_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + + // The deadline falls exactly DURATION_DAYS after the start. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + assert_error( + contribute(&mut setup, &contributor, ONE_TOKEN), + FundraiserError::FundraiserEnded, + ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0 ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), CONTRIBUTOR_STARTING_BALANCE ); } @@ -425,26 +581,11 @@ fn test_contribute_after_deadline_fails() { fn test_contribute_below_one_major_unit_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN - 1, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "Contributions below one major unit must fail" + assert_error( + contribute(&mut setup, &contributor, ONE_TOKEN - 1), + FundraiserError::ContributionTooSmall, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -456,40 +597,17 @@ fn test_contribute_below_one_major_unit_fails() { fn test_refund_before_deadline_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, ONE_TOKEN).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); + // One day short of the deadline. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 - 1); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - - // Still inside the window: refund must fail with FundraiserNotEnded. - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), + let fee_payer = contributor.keypair.insecure_clone(); + assert_error( + refund(&mut setup, &fee_payer, &contributor), + FundraiserError::FundraiserNotEnded, ); - assert!(result.is_err(), "Refunding before the deadline must fail"); - assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), ONE_TOKEN @@ -502,105 +620,74 @@ fn test_refund_before_deadline_fails() { fn test_refund_after_deadline_target_not_met_succeeds() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); // Past the deadline, target not met: refund must succeed. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, &contributor).unwrap(); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0 ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), CONTRIBUTOR_STARTING_BALANCE ); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); assert_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); assert!( - setup.svm.get_account(&contributor_account_pda).is_none(), + setup + .svm + .get_account(&contributor.contributor_account_pda) + .is_none(), "Contributor account must be closed after refund" ); } #[test] -fn test_refund_when_target_met_fails() { +fn test_anyone_can_refund_a_contributor() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - // 10 contributors at the 10% cap reach the target exactly. - let mut contributors = Vec::new(); - for _ in 0..10 { - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - contributors.push((contributor, contributor_ata, contributor_account_pda)); - } - - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + // The maker sends the refund. The tokens and the rent still go to the + // contributor, who signs nothing. + let rent = lamports(&setup, &contributor.contributor_account_pda); + let contributor_lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + let maker = setup.maker.insecure_clone(); + refund(&mut setup, &maker, &contributor).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = &contributors[0]; - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - contributor_ata, - contributor_account_pda, + assert_eq!( + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[contributor], - &contributor.pubkey(), + assert_eq!( + lamports(&setup, &contributor.keypair.pubkey()), + contributor_lamports_before + rent ); - assert!( - result.is_err(), - "Refunding must fail once the target has been met" +} + +#[test] +fn test_refund_when_target_met_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + + let fee_payer = contributors[0].keypair.insecure_clone(); + assert_error( + refund(&mut setup, &fee_payer, &contributors[0]), + FundraiserError::TargetMet, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -609,195 +696,189 @@ fn test_refund_when_target_met_fails() { } #[test] -fn test_check_contributions_success_pays_maker_and_closes_vault() { +fn test_check_contributions_success_pays_maker_and_marks_claimed() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); - // 10 contributors at the 10% cap reach the target exactly. - for _ in 0..10 { - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - } + let maker_ata = claim(&mut setup).unwrap(); assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), AMOUNT_TO_RAISE ); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + 0 + ); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); - send_transaction_from_instructions( + // The fundraiser stays open, marked claimed, until every contributor + // account written for it is closed. + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(fundraiser_state.claimed); + assert_eq!( + fundraiser_state.open_contributor_accounts, + CONTRIBUTIONS_REACHING_TARGET.len() as u32 + ); +} + +#[test] +fn test_second_claim_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + let maker_ata = claim(&mut setup).unwrap(); + + // A donation to the vault after the claim must not make a second claim + // possible. + mint_tokens_to_token_account( &mut setup.svm, - vec![check_instruction], - &[&setup.maker], - &setup.maker.pubkey(), + &setup.mint, + &setup.vault, + ONE_TOKEN, + &setup.payer, ) .unwrap(); + setup.svm.expire_blockhash(); + assert_error(claim(&mut setup), FundraiserError::FundraiserClaimed); assert_eq!( get_token_account_balance(&setup.svm, &maker_ata).unwrap(), AMOUNT_TO_RAISE ); - assert!( - setup.svm.get_account(&setup.vault).is_none(), - "Vault token account must be closed after a successful claim" - ); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_none(), - "Fundraiser account must be closed after a successful claim" - ); } #[test] -fn test_contribute_above_cap_fails() { +fn test_contribute_after_claim_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // One minor unit over the 10% cap must fail with ContributionTooBig. - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION + 1, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "A single contribution above the 10% cap must fail" + // The deadline is still days away, but the vault has been paid out. + warp_days_forward(&mut setup.svm, 1); + let late_contributor = new_contributor(&mut setup); + assert_error( + contribute(&mut setup, &late_contributor, CONTRIBUTION), + FundraiserError::FundraiserClaimed, ); assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - 0 + get_token_account_balance(&setup.svm, &late_contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE ); } #[test] -fn test_cumulative_contributions_above_cap_fail() { +fn test_check_contributions_ignores_direct_vault_donations() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // Each call is under the cap on its own; the second pushes the - // cumulative total over it and must fail with - // MaximumContributionsReached. - let first_contribution = 2 * ONE_TOKEN; - let second_contribution = MAX_CONTRIBUTION - ONE_TOKEN; - - let first_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - first_contribution, - ); - send_transaction_from_instructions( + // Mint the full target straight into the vault, bypassing contribute. + // The state-tracked current_amount stays 0, so the claim must fail. + mint_tokens_to_token_account( &mut setup.svm, - vec![first_instruction], - &[&contributor], - &contributor.pubkey(), + &setup.mint, + &setup.vault, + AMOUNT_TO_RAISE, + &setup.payer, ) .unwrap(); - let second_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - second_contribution, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![second_instruction], - &[&contributor], - &contributor.pubkey(), - ); + assert_error(claim(&mut setup), FundraiserError::TargetNotMet); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(!fundraiser_state.claimed); +} + +#[test] +fn test_close_contributor_after_claim_returns_rent() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + let contributor = &contributors[0]; + let rent = lamports(&setup, &contributor.contributor_account_pda); + let lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + + let fee_payer = contributor.keypair.insecure_clone(); + close_contributor(&mut setup, &fee_payer, contributor).unwrap(); + assert!( - result.is_err(), - "Contributions that cumulatively exceed the 10% cap must fail" + setup + .svm + .get_account(&contributor.contributor_account_pda) + .is_none(), + "Contributor account must be closed" ); - + // The contributor paid the transaction fee out of the same balance, so + // the rent came back less that fee. assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - first_contribution + lamports(&setup, &contributor.keypair.pubkey()), + lamports_before + rent - TRANSACTION_FEE + ); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!( + fundraiser_state.open_contributor_accounts, + CONTRIBUTIONS_REACHING_TARGET.len() as u32 - 1 ); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, first_contribution); } #[test] -fn test_close_fundraiser_after_failed_raise_allows_a_new_raise() { +fn test_anyone_can_close_contributor_accounts_after_claim() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + // The maker closes every contributor account; each rent deposit goes to + // its contributor, who signs nothing. + let maker = setup.maker.insecure_clone(); + for contributor in &contributors { + let rent = lamports(&setup, &contributor.contributor_account_pda); + let lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + close_contributor(&mut setup, &maker, contributor).unwrap(); + assert_eq!( + lamports(&setup, &contributor.keypair.pubkey()), + lamports_before + rent + ); + } - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); +} - // The raise fails; the contributor takes their refund. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_contributor_before_claim_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + + // The fundraiser is unclaimed, so the contribution can still be + // refunded: closing the account now would erase what the vault owes. + let fee_payer = contributor.keypair.insecure_clone(); + assert_error( + close_contributor(&mut setup, &fee_payer, &contributor), + FundraiserError::FundraiserNotClaimed, + ); + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!(contributor_state.amount, CONTRIBUTION); +} - // The maker retires the failed fundraiser. - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_fundraiser_after_failed_raise_allows_a_new_raise() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + // The raise fails; the contributor takes their refund. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, &contributor).unwrap(); + + close_fundraiser(&mut setup).unwrap(); assert!( setup.svm.get_account(&setup.vault).is_none(), "Vault token account must be closed with the fundraiser" @@ -827,17 +908,12 @@ fn test_close_fundraiser_before_deadline_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing a fundraiser before its deadline must fail" + // One day short of the deadline. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 - 1); + + assert_error( + close_fundraiser(&mut setup), + FundraiserError::FundraiserNotEnded, ); assert!( setup.svm.get_account(&setup.fundraiser_pda).is_some(), @@ -849,86 +925,33 @@ fn test_close_fundraiser_before_deadline_fails() { fn test_close_fundraiser_with_unrefunded_contributions_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); // Past the deadline but the contribution has not been refunded, so // closing would strand it in the vault. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing must fail while contributions remain unrefunded" + assert_error( + close_fundraiser(&mut setup), + FundraiserError::RefundsOutstanding, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - MAX_CONTRIBUTION + CONTRIBUTION ); } #[test] -fn test_close_fundraiser_when_target_met_fails() { +fn test_close_fundraiser_when_target_met_but_unclaimed_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); - // 10 contributors at the 10% cap reach the target exactly. - for _ in 0..10 { - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - } + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - - // A successful raise exits through check_contributions, never close. - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing must fail when the target was met; the claim is the exit" - ); + // A raise that met its target closes only after the maker claims it. + assert_error(close_fundraiser(&mut setup), FundraiserError::TargetMet); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), AMOUNT_TO_RAISE @@ -936,200 +959,169 @@ fn test_close_fundraiser_when_target_met_fails() { } #[test] -fn test_close_fundraiser_sweeps_direct_donations_to_maker() { +fn test_close_fundraiser_with_open_contributor_accounts_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - // Tokens sent straight to the vault are outside the program's - // accounting; on close they go to the maker instead of being burned - // with the account. - let donation = 5 * ONE_TOKEN; - mint_tokens_to_token_account( - &mut setup.svm, - &setup.mint, - &setup.vault, - donation, - &setup.payer, - ) - .unwrap(); - - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); + // Close all but one contributor account. + let maker = setup.maker.insecure_clone(); + for contributor in &contributors[1..] { + close_contributor(&mut setup, &maker, contributor).unwrap(); + } - assert_eq!( - get_token_account_balance(&setup.svm, &maker_ata).unwrap(), - donation + assert_error( + close_fundraiser(&mut setup), + FundraiserError::ContributorAccountsOpen, ); - assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); - assert!(setup.svm.get_account(&setup.vault).is_none()); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_some()); } #[test] -fn test_check_contributions_ignores_direct_vault_donations() { +fn test_reinitialize_with_open_contributor_accounts_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - // Mint the full target straight into the vault, bypassing contribute. - // The state-tracked current_amount stays 0, so the claim must fail. - mint_tokens_to_token_account( - &mut setup.svm, - &setup.mint, - &setup.vault, - AMOUNT_TO_RAISE, - &setup.payer, - ) - .unwrap(); - - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); + // The claimed fundraiser account still exists, so a new fundraiser + // cannot be initialized at its address. + setup.svm.expire_blockhash(); + let initialize_instruction = Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::InitializeFundraiser { + amount: AMOUNT_TO_RAISE, + duration: DURATION_DAYS, + } + .data(), + fundraiser::accounts::InitializeFundraiserAccountConstraints { + maker: setup.maker.pubkey(), + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + vault: setup.vault, + system_program: system_program::ID, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + } + .to_account_metas(None), + ); let result = send_transaction_from_instructions( &mut setup.svm, - vec![check_instruction], + vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), ); assert!( result.is_err(), - "Direct donations to the vault must not unlock the claim" - ); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_some(), - "Fundraiser account must stay open after a failed claim" + "A new fundraiser must not start while the claimed one exists" ); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(fundraiser_state.claimed); } #[test] -fn test_close_contributor_after_successful_claim_returns_rent() { +fn test_stale_contributor_account_cannot_refund_from_next_raise() { let mut setup = full_setup(); - initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - // 10 contributors at the 10% cap reach the target exactly. - let mut contributors = Vec::new(); - for _ in 0..10 { - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - contributors.push((contributor, contributor_account_pda)); + // Raise one succeeds and the maker claims it. + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let first_raise_contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + // The maker closes every contributor account from raise one, then the + // fundraiser, and starts raise two at the same address. + let maker = setup.maker.insecure_clone(); + for contributor in &first_raise_contributors { + close_contributor(&mut setup, &maker, contributor).unwrap(); } + close_fundraiser(&mut setup).unwrap(); + setup.svm.expire_blockhash(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![check_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_none(), - "Fundraiser account must be closed after a successful claim" + // Raise two collects less than the target and fails. + let second_raise_contributors: Vec = (0..2) + .map(|_| { + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + contributor + }) + .collect(); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + + // A raise-one contributor tries to take a refund from raise two. Their + // contributor account was closed with raise one, so there is nothing to + // refund. + let stale_contributor = &first_raise_contributors[0]; + let fee_payer = stale_contributor.keypair.insecure_clone(); + assert!(refund(&mut setup, &fee_payer, stale_contributor).is_err()); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + 2 * CONTRIBUTION ); - // The claim closed the fundraiser and the vault, but every contributor - // account is still open with its rent inside. - let (contributor, contributor_account_pda) = &contributors[0]; - let rent = setup - .svm - .get_account(contributor_account_pda) - .expect("Contributor account survives the claim") - .lamports; - let lamports_before = setup - .svm - .get_account(&contributor.pubkey()) - .unwrap() - .lamports; + // Every raise-two contributor is refunded in full. + for contributor in &second_raise_contributors { + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, contributor).unwrap(); + assert_eq!( + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE + ); + } + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); +} - let close_instruction = - build_close_contributor_instruction(&setup, &contributor.pubkey(), contributor_account_pda); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[contributor], - &contributor.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_fundraiser_after_claim_allows_a_new_raise() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - assert!( - setup.svm.get_account(contributor_account_pda).is_none(), - "Contributor account must be closed" - ); - let lamports_after = setup - .svm - .get_account(&contributor.pubkey()) - .unwrap() - .lamports; - // The contributor paid the transaction fee out of the same balance, so - // the rent came back less that fee. - let fee = 5_000; - assert_eq!( - lamports_after, - lamports_before + rent - fee, - "The contributor account's rent must return to the contributor" - ); + let maker = setup.maker.insecure_clone(); + for contributor in &contributors { + close_contributor(&mut setup, &maker, contributor).unwrap(); + } + close_fundraiser(&mut setup).unwrap(); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); + assert!(setup.svm.get_account(&setup.vault).is_none()); + + setup.svm.expire_blockhash(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(!fundraiser_state.claimed); + assert_eq!(fundraiser_state.current_amount, 0); } #[test] -fn test_close_contributor_while_fundraiser_open_fails() { +fn test_close_fundraiser_sweeps_direct_donations_to_maker() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( + // Tokens sent straight to the vault are outside the program's + // accounting; on close they go to the maker instead of being burned + // with the account. + let donation = 5 * ONE_TOKEN; + mint_tokens_to_token_account( &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), + &setup.mint, + &setup.vault, + donation, + &setup.payer, ) .unwrap(); - // The fundraiser is live, so the contribution is live too: closing the - // record now would erase what the vault owes this contributor. - let close_instruction = build_close_contributor_instruction( - &setup, - &contributor.pubkey(), - &contributor_account_pda, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "Closing a contributor account must fail while its fundraiser exists" + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + close_fundraiser(&mut setup).unwrap(); + + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + assert_eq!( + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), + donation ); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); + assert!(setup.svm.get_account(&setup.vault).is_none()); } diff --git a/finance/fundraiser/kani-proofs/README.md b/finance/fundraiser/kani-proofs/README.md index dd4ebba7e..ffa786926 100644 --- a/finance/fundraiser/kani-proofs/README.md +++ b/finance/fundraiser/kani-proofs/README.md @@ -14,20 +14,19 @@ one. The program collects contributions toward a goal; if the goal is not met by the deadline, every contributor reclaims their exact stake. Token movement is via SPL CPIs Kani cannot symbolically execute, but the accounting (`contribute`, -`refund`) is pure integer arithmetic, and the harnesses check it for every input in the +`refund`, `close_contributor`) is pure integer arithmetic, and the harnesses check it for every input in the declared ranges: -- `proof_contribution_cap_bounds`: The per-contributor cap never exceeds the goal, and the `cumulative <= cap` check keeps every contributor at or below it (and below the goal). +- `proof_open_contributor_accounts_counts_open_accounts`: `open_contributor_accounts` always equals the number of contributor accounts that exist, over every sequence of eight contributions, refunds and closes among three contributors. `close_fundraiser` requires it to be zero, so no contributor account carries over into the next raise at the same address. - `proof_current_amount_is_sum_of_contributions`: `current_amount` always equals the sum of the contributions added to it, no accounting drift. - `proof_refunds_sum_to_current_amount`: On a failed raise, refunds sum back to `current_amount`; no contributor reclaims more than they put in. -The cap harness checks nonlinear arithmetic (`goal · pct / scaler`) over -bounded inputs; the two accounting/refund harnesses are pure linear logic -and run at full `u64` width (bounded only in the number of contributors). The -whole suite finishes in under a second. +The counter harness explores every sequence of steps up to its bound; the two +accounting/refund harnesses are pure linear logic and run at full `u64` width +(bounded only in the number of contributors). Run weekly in CI (the `kani.yml` `verify` job), not on every push/PR, because -the nonlinear model checks are slow. A fast unit-test job runs per push/PR. +model checks are slow. A fast unit-test job runs per push/PR. ## Running diff --git a/finance/fundraiser/kani-proofs/src/lib.rs b/finance/fundraiser/kani-proofs/src/lib.rs index be1f9e2d5..26130e57e 100644 --- a/finance/fundraiser/kani-proofs/src/lib.rs +++ b/finance/fundraiser/kani-proofs/src/lib.rs @@ -10,58 +10,83 @@ //! The program collects contributions into a vault toward a goal; if the goal //! is not met by the deadline, every contributor reclaims their exact stake. //! Token movement is via SPL CPIs Kani cannot symbolically execute, but the -//! accounting (`contribute`, `refund`) is pure integer arithmetic. This crate -//! reproduces it faithfully and checks the per-contributor cap, the running- -//! total accounting, and refund conservation. +//! accounting (`contribute`, `refund`, `close_contributor`) is pure integer +//! arithmetic. This crate reproduces it faithfully and checks the contributor +//! account counter, the running-total accounting, and refund conservation. #![cfg_attr(kani, allow(dead_code))] -/// `contribute::MAX_CONTRIBUTION_PERCENTAGE` / `PERCENTAGE_SCALER`. The program -/// ships these as a percentage cap; the exact values do not matter to the check, -/// only that the cap is `goal * pct / scaler`. -pub const MAX_CONTRIBUTION_PERCENTAGE: u128 = 10; // 10% -pub const PERCENTAGE_SCALER: u128 = 100; - -/// `calculate_max_contribution`: the per-contributor cap is a fixed percentage -/// of the goal. -pub fn max_contribution(amount_to_raise: u64) -> Option { - ((amount_to_raise as u128) * MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER) - .try_into() - .ok() +/// How many contributors the counter harness tracks. +pub const CONTRIBUTORS: usize = 3; + +/// One step of a fundraiser's life, as it affects contributor accounts. +#[derive(Clone, Copy)] +pub enum ContributorAccountStep { + /// `contribute` from this contributor: creates their account on the first + /// call and adds to it on later ones. + Contribute(usize), + /// `refund` or `close_contributor` for this contributor: both close the + /// account, and both fail if it does not exist. + Close(usize), +} + +/// Replays `contribute`, `refund` and `close_contributor`'s bookkeeping on +/// `open_contributor_accounts`. Returns `None` where the program would reject +/// the step, as it does when the account to close does not exist. +pub fn apply_step( + open_accounts: &mut [bool; CONTRIBUTORS], + open_contributor_accounts: u32, + step: ContributorAccountStep, +) -> Option { + match step { + ContributorAccountStep::Contribute(contributor) => { + if open_accounts[contributor] { + Some(open_contributor_accounts) + } else { + open_accounts[contributor] = true; + open_contributor_accounts.checked_add(1) + } + } + ContributorAccountStep::Close(contributor) => { + if !open_accounts[contributor] { + return None; + } + open_accounts[contributor] = false; + open_contributor_accounts.checked_sub(1) + } + } } // =========================================================================== -// 1. Per-contributor cap +// 1. Contributor account counter // =========================================================================== -/// The cap is never more than the goal itself, and `contribute`'s -/// `cumulative <= max_contribution` check keeps every contributor's running -/// total within it (so `checked_add` of a new contribution onto an at-cap -/// balance can only succeed below the cap). Captures the bound the on-chain -/// `MaximumContributionsReached` guard enforces. +/// `fundraiser.open_contributor_accounts` always equals the number of +/// contributor accounts that exist for the fundraiser, whatever order +/// contributions, refunds and closes arrive in. `close_fundraiser` requires +/// the counter to be zero, so this is what guarantees no contributor account +/// outlives its fundraiser and carries over into the next raise at the same +/// address. #[cfg(kani)] #[kani::proof] -#[kani::solver(cadical)] -fn proof_contribution_cap_bounds() { - let amount_to_raise: u64 = kani::any(); - let prior: u64 = kani::any(); // contributor's existing cumulative total - let amount: u64 = kani::any(); // new contribution - - kani::assume(amount_to_raise as u128 <= 4095); - - let cap = max_contribution(amount_to_raise).expect("computes"); - // The cap never exceeds the goal (10% <= 100%). - assert!(cap as u128 <= amount_to_raise as u128); - - // The on-chain check: a contribution is accepted only if the new cumulative - // stays within the cap. - kani::assume(prior <= cap); - if let Some(cumulative) = prior.checked_add(amount) { - if cumulative <= cap { - // Accepted contributions keep the contributor at or below the cap. - assert!(cumulative <= cap); - assert!(cumulative <= amount_to_raise); // ...and below the goal +#[kani::unwind(9)] +fn proof_open_contributor_accounts_counts_open_accounts() { + let mut open_accounts = [false; CONTRIBUTORS]; + let mut open_contributor_accounts: u32 = 0; + + for _ in 0..8 { + let contributor: usize = kani::any(); + kani::assume(contributor < CONTRIBUTORS); + let step = if kani::any() { + ContributorAccountStep::Contribute(contributor) + } else { + ContributorAccountStep::Close(contributor) + }; + if let Some(updated) = apply_step(&mut open_accounts, open_contributor_accounts, step) { + open_contributor_accounts = updated; } + let actually_open = open_accounts.iter().filter(|open| **open).count() as u32; + assert_eq!(open_contributor_accounts, actually_open); } } @@ -131,9 +156,24 @@ mod tests { use super::*; #[test] - fn cap_is_ten_percent() { - assert_eq!(max_contribution(1000).unwrap(), 100); - assert!(max_contribution(1000).unwrap() <= 1000); + fn counter_tracks_contributor_accounts() { + let mut open_accounts = [false; CONTRIBUTORS]; + let mut counter = 0u32; + for step in [ + ContributorAccountStep::Contribute(0), + ContributorAccountStep::Contribute(0), + ContributorAccountStep::Contribute(2), + ContributorAccountStep::Close(0), + ] { + counter = apply_step(&mut open_accounts, counter, step).unwrap(); + } + assert_eq!(counter, 1); + assert!(apply_step( + &mut open_accounts, + counter, + ContributorAccountStep::Close(1) + ) + .is_none()); } #[test] From 5a95b6c0fef8cda05f641e527b731d3eb25e0a6a Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 19:38:55 +0000 Subject: [PATCH 2/5] fundraiser (quasar): keep the fundraiser until its receipts close; add close_fundraiser Port of the Anchor v2 fix. check_contributions sets claimed instead of closing the Fundraiser and vault; the Fundraiser counts open Contributor accounts; the new close_fundraiser handler requires that count to be zero. refund and close_contributor no longer need the contributor's signature, and refund checks the destination token account belongs to the contributor. 31 tests, including two raises at the same address. Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- finance/fundraiser/quasar/CHANGELOG.md | 32 + finance/fundraiser/quasar/README.md | 20 +- finance/fundraiser/quasar/src/error.rs | 14 +- .../src/instructions/check_contributions.rs | 31 +- .../src/instructions/close_contributor.rs | 55 +- .../src/instructions/close_fundraiser.rs | 119 ++++ .../quasar/src/instructions/contribute.rs | 22 +- .../src/instructions/initialize_fundraiser.rs | 2 + .../fundraiser/quasar/src/instructions/mod.rs | 3 + .../quasar/src/instructions/refund.rs | 19 +- finance/fundraiser/quasar/src/lib.rs | 23 +- finance/fundraiser/quasar/src/state.rs | 8 + finance/fundraiser/quasar/src/tests.rs | 563 +++++++++++++++--- 13 files changed, 789 insertions(+), 122 deletions(-) create mode 100644 finance/fundraiser/quasar/src/instructions/close_fundraiser.rs diff --git a/finance/fundraiser/quasar/CHANGELOG.md b/finance/fundraiser/quasar/CHANGELOG.md index bad0263b1..911c26f43 100644 --- a/finance/fundraiser/quasar/CHANGELOG.md +++ b/finance/fundraiser/quasar/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## [2026-10-01] + +### Fixed + +- A Contributor account could outlive its fundraiser and count toward the + next one. `check_contributions` closed the Fundraiser account while the + Contributor accounts derived from its address stayed open, so the maker + could initialize a new fundraiser at the same address and `refund` would pay + a leftover account's old amount out of the new contributors' tokens. + `check_contributions` now sets a new `claimed` flag and leaves the Fundraiser + and vault open, and the Fundraiser counts `open_contributor_accounts`. + +### Added + +- `close_fundraiser` (discriminator 5): closes the vault and the Fundraiser + once no Contributor account is open (`ContributorAccountsOpen`), and on an + unclaimed fundraiser only after the deadline, with the target missed and + every contribution refunded (`FundraiserNotEnded`, `TargetMet`, the new + `RefundsOutstanding`). Any tokens left in the vault go to the maker. +- `FundraiserClaimed`: `contribute` and `check_contributions` refuse a claimed + fundraiser. + +### Changed + +- `close_contributor` requires the fundraiser to be claimed + (`FundraiserNotClaimed`, replacing `FundraiserStillOpen`). +- `refund` and `close_contributor` no longer require the contributor's + signature, so the maker can close every Contributor account. `refund` + checks that the destination token account belongs to the contributor. + `quasar build` reports P006 (instruction missing signer) for both; that is + intended. + ## [2026-09-28] ### Changed diff --git a/finance/fundraiser/quasar/README.md b/finance/fundraiser/quasar/README.md index 5bcd81dfc..0bced1c48 100644 --- a/finance/fundraiser/quasar/README.md +++ b/finance/fundraiser/quasar/README.md @@ -1,6 +1,6 @@ # Solana Fundraiser (Quasar) -Onchain crowdfunding on Solana toward a target amount in a chosen token, written with [Quasar](https://quasar-lang.com/docs). A **maker** opens a fundraiser with a target amount and a deadline; **contributors** deposit tokens into a program-controlled vault. If the target is met the maker withdraws everything; if the deadline passes without the target being met, each contributor reclaims exactly what they put in. +Onchain crowdfunding on Solana toward a target amount in a chosen token, written with [Quasar](https://quasar-lang.com/docs). A **maker** opens a fundraiser with a target amount and a deadline; **contributors** deposit tokens into a program-controlled vault. If the target is met the maker withdraws everything; if the deadline passes without the target being met, each contributor gets back exactly what they put in. Either way, once every contributor's record is closed the maker closes the fundraiser and can open another. This example was called **Token Fundraiser** (`finance/token-fundraiser`) until it was renamed: contributors receive no token, only a refund if the target is missed. @@ -8,18 +8,20 @@ See also: the [repository catalog](../../../README.md) and the [Anchor variant]( ## Major concepts -- The **Fundraiser** account is a PDA at `["fundraiser", maker]`. It stores the maker, the token's mint, the vault address, the target (`amount_to_raise`), the running total (`current_amount`), the Clock timestamp captured at creation (`time_started`), the window length in days (`duration`), and the PDA bump. Storing the vault address lets every later instruction bind the passed vault to this fundraiser with a `has_one(vault)` constraint. -- A **Contributor** account is a PDA at `["contributor", fundraiser, contributor]`. It records how much that signer has given to that fundraiser, plus its bump. The seeds bind the record to one (fundraiser, contributor) pair, so one contributor's record can never be spent by another signer or against another fundraiser. +- The **Fundraiser** account is a PDA at `["fundraiser", maker]`. It stores the maker, the token's mint, the vault address, the target (`amount_to_raise`), the running total (`current_amount`), the Clock timestamp captured at creation (`time_started`), the window length in days (`duration`), whether the maker has claimed it (`claimed`), how many Contributor accounts written for it are still open (`open_contributor_accounts`), and the PDA bump. Storing the vault address lets every later instruction bind the passed vault to this fundraiser with a `has_one(vault)` constraint. +- A **Contributor** account is a PDA at `["contributor", fundraiser, contributor]`. It records how much that contributor has given to that fundraiser, plus its bump. The seeds bind the record to one (fundraiser, contributor) pair, so one contributor's record can never be paid to anyone else or against another fundraiser. - The **vault** is a token account whose authority is the Fundraiser PDA. All deposits, the maker payout, and refunds flow through it, with the PDA signing outbound transfers via its seeds. +- **The Fundraiser outlives every Contributor account.** A Contributor PDA is derived from the Fundraiser's address, and the Fundraiser's address from the maker alone. If the Fundraiser could close while a Contributor account was open, the maker could initialize a new fundraiser at the same address and that Contributor account would count as a contribution to it, so `refund` would pay its old amount out of the new contributors' tokens. `open_contributor_accounts` counts them, and `close_fundraiser` requires zero. - The **fundraising window** runs from `time_started` for `duration` days. Contributions are allowed while `now < time_started + duration`; refunds are allowed once `now >= time_started + duration` and only if the target was not met. `now` is the Clock sysvar's unix timestamp. ## Lifecycle -- `initialize` (maker signs): rejects a zero target (`InvalidAmount`) or zero duration (`InvalidDuration`), creates the Fundraiser PDA and the vault, and records the current Clock time as `time_started`. -- `contribute` (contributor signs): rejects a zero amount and, after the deadline, fails with `FundraiserEnded`. Creates the contributor's Contributor PDA on first use (idempotent init, contributor pays the rent), adds the amount to both `current_amount` and the contributor's record with checked arithmetic, transfers tokens from the contributor's token account into the vault, then verifies the vault gained exactly the contributed amount (`BalanceMismatch` otherwise). -- `check_contributions` (maker signs): fails with `TargetNotMet` unless `current_amount >= amount_to_raise`. Transfers the whole vault balance to the maker's token account with the Fundraiser PDA signing, then closes the vault and the Fundraiser account, returning their rent to the maker. -- `refund` (contributor signs): fails with `FundraiserNotEnded` before the deadline and with `TargetMet` if the fundraiser succeeded. Pays the contributor's recorded amount back from the vault with the PDA signing, subtracts it from `current_amount`, verifies the vault lost exactly that amount, and closes the Contributor account back to the contributor. -- `close_contributor` (contributor signs): fails with `FundraiserStillOpen` while the passed fundraiser account is still owned by this program. A successful raise closes the vault and the Fundraiser account but leaves every Contributor account open, and `refund` runs only on a failed raise, so this is how a contributor to a successful raise takes back their rent. The Contributor account's seeds bind it to the fundraiser address, so no other fundraiser can be substituted; the account is closed back to the contributor. +- `initialize_fundraiser` (maker signs): rejects a zero target (`InvalidAmount`) or zero duration (`InvalidDuration`), creates the Fundraiser PDA and the vault, and records the current Clock time as `time_started`, with `claimed` false and `open_contributor_accounts` zero. +- `contribute` (contributor signs): rejects a zero amount, fails with `FundraiserClaimed` once the maker has claimed, and fails with `FundraiserEnded` after the deadline. Creates the contributor's Contributor PDA on first use (idempotent init, contributor pays the rent) and adds one to `open_contributor_accounts`, adds the amount to both `current_amount` and the contributor's record with checked arithmetic, transfers tokens from the contributor's token account into the vault, then verifies the vault gained exactly the contributed amount (`BalanceMismatch` otherwise). +- `check_contributions` (maker signs): fails with `FundraiserClaimed` if already claimed and with `TargetNotMet` unless `current_amount >= amount_to_raise`. Sets `claimed` and transfers the whole vault balance to the maker's token account with the Fundraiser PDA signing. The Fundraiser and the empty vault stay open until every Contributor account is closed. +- `refund` (anyone may send it; the contributor does not sign): fails with `FundraiserNotEnded` before the deadline and with `TargetMet` if the fundraiser succeeded. Pays the contributor's recorded amount back from the vault to the contributor's own token account (checked against the contributor's address) with the PDA signing, subtracts it from `current_amount` and one from `open_contributor_accounts`, verifies the vault lost exactly that amount, and closes the Contributor account, returning its rent to the contributor. The tokens and rent can only reach the contributor, so the maker can refund everyone without waiting on them. +- `close_contributor` (anyone may send it; the contributor does not sign): fails with `FundraiserNotClaimed` until the maker has claimed, because before then the contribution can still be refunded. Closes the Contributor account, returning its rent to the contributor, and subtracts one from `open_contributor_accounts`. The Contributor account's seeds bind it to the fundraiser address, so no other fundraiser can be substituted. +- `close_fundraiser` (maker signs): for an unclaimed fundraiser, fails with `FundraiserNotEnded` before the deadline, `TargetMet` if the target was met, and `RefundsOutstanding` while `current_amount` is above zero. For any fundraiser, fails with `ContributorAccountsOpen` while `open_contributor_accounts` is above zero. Pays any tokens left in the vault (direct transfers outside `contribute`) to the maker's token account, then closes the vault and the Fundraiser account, returning their rent to the maker. Errors are defined in `src/error.rs` as a `#[error_code]` enum starting at code 6000. @@ -43,4 +45,4 @@ In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`): quasar test ``` -The tests in `src/tests.rs` drive the real instruction handlers end to end (initialize, contribute, check_contributions, refund, close_contributor), assert vault and contributor token balances plus account state after every step, and use `QuasarSvm::warp_to_timestamp` to test both sides of the deadline. They also cover the rejection paths: contributing after the deadline, refunding early or after a successful raise, paying out below target, passing a vault not bound to the fundraiser, refunding against another contributor's record, and closing a contributor account while its fundraiser still exists. No local validator is needed. +The tests in `src/tests.rs` drive the real instruction handlers end to end (initialize_fundraiser, contribute, check_contributions, refund, close_contributor, close_fundraiser), assert vault and contributor token balances plus account state after every step, and use `QuasarSvm::warp_to_timestamp` to test both sides of the deadline. They also cover the rejection paths: contributing after the deadline, refunding early or after a successful raise, paying out below target, passing a vault not bound to the fundraiser, refunding against another contributor's record, closing a contributor account before the claim, and closing the fundraiser early, with contributions unrefunded, or with Contributor accounts open. `stale_contributor_account_cannot_refund_from_next_raise` (see `src/tests.rs`) runs two raises at the same address and checks that a first-raise contributor cannot take a refund from the second. No local validator is needed. diff --git a/finance/fundraiser/quasar/src/error.rs b/finance/fundraiser/quasar/src/error.rs index c3c7d56ab..1e2762aac 100644 --- a/finance/fundraiser/quasar/src/error.rs +++ b/finance/fundraiser/quasar/src/error.rs @@ -22,7 +22,15 @@ pub enum FundraiserError { MathOverflow, /// A token balance after a transfer did not match the expected value. BalanceMismatch, - /// The fundraiser account still exists, so the contribution is live and - /// its contributor account closes through refund, not here. - FundraiserStillOpen, + /// The fundraiser has already been claimed. + FundraiserClaimed, + /// The fundraiser has not been claimed, so the contributor account closes + /// through refund. + FundraiserNotClaimed, + /// Contributor accounts for this fundraiser are still open, so it cannot + /// close yet. + ContributorAccountsOpen, + /// Contributions to an unclaimed fundraiser have not all been refunded, + /// so closing its vault would strand them. + RefundsOutstanding, } diff --git a/finance/fundraiser/quasar/src/instructions/check_contributions.rs b/finance/fundraiser/quasar/src/instructions/check_contributions.rs index deee38dcc..633fa628b 100644 --- a/finance/fundraiser/quasar/src/instructions/check_contributions.rs +++ b/finance/fundraiser/quasar/src/instructions/check_contributions.rs @@ -7,7 +7,6 @@ use { #[derive(Accounts)] pub struct CheckContributionsAccountConstraints { - #[account(mut)] pub maker: Signer, #[account( @@ -15,7 +14,6 @@ pub struct CheckContributionsAccountConstraints { has_one(maker), has_one(vault), has_one(mint_to_raise), - close(dest = maker), address = Fundraiser::seeds(maker.address()), )] pub fundraiser: Account, @@ -33,11 +31,27 @@ pub struct CheckContributionsAccountConstraints { pub token_program: Program, } +/// Pays the vault out to the maker once the target is met, and marks the +/// fundraiser claimed. +/// +/// The fundraiser account and the vault stay open: contributor accounts are +/// derived from the fundraiser's address, so the fundraiser must outlive every +/// one of them. Otherwise the maker could initialize a new fundraiser at the +/// same address, and contributor accounts left over from this raise would +/// count as contributions to the new one. `close_contributor` closes them, +/// then `close_fundraiser` closes the fundraiser and the vault. #[inline(always)] pub fn handle_check_contributions( accounts: &mut CheckContributionsAccountConstraints, bumps: &CheckContributionsAccountConstraintsBumps, ) -> Result<(), ProgramError> { + require!( + !bool::from(accounts.fundraiser.claimed), + FundraiserError::FundraiserClaimed + ); + + // Compare the state-tracked total, not the vault balance, so tokens + // donated directly to the vault cannot trigger an early release. let current_amount: u64 = accounts.fundraiser.current_amount.into(); let amount_to_raise: u64 = accounts.fundraiser.amount_to_raise.into(); require!( @@ -45,6 +59,9 @@ pub fn handle_check_contributions( FundraiserError::TargetNotMet ); + // Update state before the transfer CPI (checks-effects-interactions). + accounts.fundraiser.claimed = PodBool::from(true); + // Fundraiser PDA signer seeds: ["fundraiser", maker, bump]. let bump = [bumps.fundraiser]; let seeds = [ @@ -53,7 +70,7 @@ pub fn handle_check_contributions( Seed::from(bump.as_ref()), ]; - // Transfer all vault funds to the maker. + // Pay the whole vault (including any direct donations) to the maker. let vault_amount = accounts.vault.amount(); accounts .token_program @@ -67,17 +84,11 @@ pub fn handle_check_contributions( ) .invoke_signed(&seeds)?; - // Token conservation: the vault was fully drained. + // Token conservation: the vault was fully paid out. require!( accounts.vault.amount() == 0, FundraiserError::BalanceMismatch ); - // Close the vault token account, returning its rent to the maker. - accounts - .token_program - .close_account(&accounts.vault, &accounts.maker, &accounts.fundraiser) - .invoke_signed(&seeds)?; - Ok(()) } diff --git a/finance/fundraiser/quasar/src/instructions/close_contributor.rs b/finance/fundraiser/quasar/src/instructions/close_contributor.rs index 6612009e3..2e9ec8ca1 100644 --- a/finance/fundraiser/quasar/src/instructions/close_contributor.rs +++ b/finance/fundraiser/quasar/src/instructions/close_contributor.rs @@ -1,23 +1,24 @@ use { - crate::{error::FundraiserError, state::Contributor}, + crate::{ + error::FundraiserError, + state::{Contributor, Fundraiser}, + }, quasar_lang::prelude::*, }; #[derive(Accounts)] pub struct CloseContributorAccountConstraints { + /// Not a signer: the rent goes to the contributor, whoever sends the + /// transaction. So a maker can close every contributor account and then + /// the fundraiser without waiting on any contributor. #[account(mut)] - pub contributor: Signer, + pub contributor: SystemAccount, /// The fundraiser this contributor account was written for. The /// contributor account's seeds bind it to this address, so no other - /// fundraiser can be substituted. The constraint requires the account to - /// be gone: a live fundraiser is owned by this program, and a closed one - /// belongs to the system program again, whatever lamports it holds. - #[account( - constraints(fundraiser.to_account_view().owner() != &crate::ID) - @ FundraiserError::FundraiserStillOpen - )] - pub fundraiser: UncheckedAccount, + /// fundraiser can be substituted. + #[account(mut)] + pub fundraiser: Account, #[account( mut, @@ -27,23 +28,29 @@ pub struct CloseContributorAccountConstraints { pub contributor_account: Account, } -/// Closes a contributor account once its fundraiser is gone, returning the -/// rent to the contributor. +/// Closes a contributor account once its fundraiser has been claimed, +/// returning the rent to the contributor. /// -/// A successful raise exits through `check_contributions`, which closes the -/// vault and the fundraiser but cannot reach the contributor accounts: there -/// is one per contributor and the claim carries none of them. Their other -/// closer, `refund`, runs only on a failed raise. Without this handler every -/// contributor to a successful raise would hold their rent in an account -/// nothing could close. -/// -/// The one check is that the fundraiser account no longer exists, which is -/// the `constraints` above; the `close(dest = contributor)` constraint then -/// returns the rent. While the fundraiser exists the contribution is live, -/// and `refund` is the way to close it. +/// `refund` closes contributor accounts on a failed raise. On a successful +/// one the contribution has been paid out to the maker, so the account only +/// holds rent, and `close_fundraiser` cannot run until every one of them is +/// closed. While the fundraiser is unclaimed the contribution can still be +/// refunded, so this handler refuses with `FundraiserNotClaimed`. #[inline(always)] pub fn handle_close_contributor( - _accounts: &mut CloseContributorAccountConstraints, + accounts: &mut CloseContributorAccountConstraints, ) -> Result<(), ProgramError> { + require!( + bool::from(accounts.fundraiser.claimed), + FundraiserError::FundraiserNotClaimed + ); + + let open_contributor_accounts: u32 = accounts.fundraiser.open_contributor_accounts.into(); + accounts.fundraiser.open_contributor_accounts = PodU32::from( + open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?, + ); + Ok(()) } diff --git a/finance/fundraiser/quasar/src/instructions/close_fundraiser.rs b/finance/fundraiser/quasar/src/instructions/close_fundraiser.rs new file mode 100644 index 000000000..6f6604835 --- /dev/null +++ b/finance/fundraiser/quasar/src/instructions/close_fundraiser.rs @@ -0,0 +1,119 @@ +use quasar_lang::cpi::Seed; +use { + crate::{ + error::FundraiserError, + state::{fundraiser_deadline, Fundraiser}, + }, + quasar_lang::{prelude::*, sysvars::Sysvar as _}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct CloseFundraiserAccountConstraints { + #[account(mut)] + pub maker: Signer, + + #[account( + mut, + has_one(maker), + has_one(vault), + has_one(mint_to_raise), + close(dest = maker), + address = Fundraiser::seeds(maker.address()), + )] + pub fundraiser: Account, + + #[account(mut)] + pub vault: Account, + + #[account(mut)] + pub maker_ta: Account, + + // Bound to fundraiser.mint_to_raise by has_one above; carries the decimals + // that transfer_checked validates against the vault and maker_ta. + pub mint_to_raise: Account, + + pub token_program: Program, +} + +/// Closes a finished fundraiser and its vault so the maker can raise again. +/// +/// The fundraiser PDA is derived from the maker's address alone, so while a +/// fundraiser account exists the maker cannot initialize another one. It +/// closes once no contributor account written for it is still open: after a +/// claim, once `close_contributor` has closed each one; after a failed raise, +/// once the deadline has passed and `refund` has closed each one. +#[inline(always)] +pub fn handle_close_fundraiser( + accounts: &mut CloseFundraiserAccountConstraints, + bumps: &CloseFundraiserAccountConstraintsBumps, +) -> Result<(), ProgramError> { + if !bool::from(accounts.fundraiser.claimed) { + // Closing an unclaimed fundraiser is allowed only after it has ended + // (now >= start + duration). + let now: i64 = Clock::get()?.unix_timestamp.into(); + let deadline = fundraiser_deadline( + accounts.fundraiser.time_started.into(), + accounts.fundraiser.duration.into(), + )?; + require!(now >= deadline, FundraiserError::FundraiserNotEnded); + + // A raise that met its target closes after the maker claims it. + let current_amount: u64 = accounts.fundraiser.current_amount.into(); + let amount_to_raise: u64 = accounts.fundraiser.amount_to_raise.into(); + require!(current_amount < amount_to_raise, FundraiserError::TargetMet); + + // Closing the vault while contributions remain would strand the + // refunds, so every contributor must have been refunded first. + require!(current_amount == 0, FundraiserError::RefundsOutstanding); + } + + // A contributor account left open would be read as a contribution to the + // next fundraiser at this address. + let open_contributor_accounts: u32 = accounts.fundraiser.open_contributor_accounts.into(); + require!( + open_contributor_accounts == 0, + FundraiserError::ContributorAccountsOpen + ); + + // Fundraiser PDA signer seeds: ["fundraiser", maker, bump]. + let bump = [bumps.fundraiser]; + let seeds = [ + Seed::from(b"fundraiser" as &[u8]), + Seed::from(accounts.maker.address().as_ref()), + Seed::from(bump.as_ref()), + ]; + + // The claim or the refunds have already paid out every tracked + // contribution, so anything left in the vault is a direct donation; pay + // it to the maker rather than burn it with the account. + let vault_amount = accounts.vault.amount(); + if vault_amount > 0 { + accounts + .token_program + .transfer_checked( + &accounts.vault, + &accounts.mint_to_raise, + &accounts.maker_ta, + &accounts.fundraiser, + vault_amount, + accounts.mint_to_raise.decimals(), + ) + .invoke_signed(&seeds)?; + + // Token conservation: the vault was fully paid out. + require!( + accounts.vault.amount() == 0, + FundraiserError::BalanceMismatch + ); + } + + // Close the empty vault, returning its rent to the maker. The + // `close(dest = maker)` constraint then closes the fundraiser account. + accounts + .token_program + .close_account(&accounts.vault, &accounts.maker, &accounts.fundraiser) + .invoke_signed(&seeds)?; + + Ok(()) +} diff --git a/finance/fundraiser/quasar/src/instructions/contribute.rs b/finance/fundraiser/quasar/src/instructions/contribute.rs index 683cd06fd..6681cbab4 100644 --- a/finance/fundraiser/quasar/src/instructions/contribute.rs +++ b/finance/fundraiser/quasar/src/instructions/contribute.rs @@ -54,6 +54,13 @@ pub fn handle_contribute( ) -> Result<(), ProgramError> { require!(amount > 0, FundraiserError::InvalidAmount); + // A claimed fundraiser has paid its vault out to the maker, so a later + // contribution would go to the maker with no refund path. + require!( + !bool::from(accounts.fundraiser.claimed), + FundraiserError::FundraiserClaimed + ); + // Contributions are allowed while now < start + duration. let now: i64 = Clock::get()?.unix_timestamp.into(); let deadline = fundraiser_deadline( @@ -76,7 +83,20 @@ pub fn handle_contribute( .checked_add(amount) .ok_or(FundraiserError::MathOverflow)?, ); - accounts.contributor_account.bump = bumps.contributor_account; + + // `init(idempotent)` creates the contributor account zeroed and reuses it + // on later contributions. Every contribution is nonzero, so a recorded + // amount of zero means the account was created by this instruction: save + // its bump and count it against the fundraiser. + if contributed_so_far == 0 { + accounts.contributor_account.bump = bumps.contributor_account; + let open_contributor_accounts: u32 = accounts.fundraiser.open_contributor_accounts.into(); + accounts.fundraiser.open_contributor_accounts = PodU32::from( + open_contributor_accounts + .checked_add(1) + .ok_or(FundraiserError::MathOverflow)?, + ); + } let vault_balance_before = accounts.vault.amount(); diff --git a/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs b/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs index 9ac241f0e..263b33224 100644 --- a/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs +++ b/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs @@ -53,6 +53,8 @@ pub fn handle_initialize_fundraiser( current_amount: 0, time_started, duration, + claimed: PodBool::from(false), + open_contributor_accounts: 0, bump, }); Ok(()) diff --git a/finance/fundraiser/quasar/src/instructions/mod.rs b/finance/fundraiser/quasar/src/instructions/mod.rs index ffb82e184..932e6663e 100644 --- a/finance/fundraiser/quasar/src/instructions/mod.rs +++ b/finance/fundraiser/quasar/src/instructions/mod.rs @@ -12,3 +12,6 @@ pub use refund::*; pub mod close_contributor; pub use close_contributor::*; + +pub mod close_fundraiser; +pub use close_fundraiser::*; diff --git a/finance/fundraiser/quasar/src/instructions/refund.rs b/finance/fundraiser/quasar/src/instructions/refund.rs index 465fa4b17..c548df182 100644 --- a/finance/fundraiser/quasar/src/instructions/refund.rs +++ b/finance/fundraiser/quasar/src/instructions/refund.rs @@ -10,8 +10,12 @@ use { #[derive(Accounts)] pub struct RefundAccountConstraints { + /// Not a signer: the tokens go to the contributor's token account and the + /// rent to the contributor, whoever sends the transaction. So a maker can + /// refund every contributor and close a failed fundraiser without waiting + /// on any of them. #[account(mut)] - pub contributor: Signer, + pub contributor: SystemAccount, pub maker: UncheckedAccount, @@ -31,7 +35,12 @@ pub struct RefundAccountConstraints { )] pub contributor_account: Account, - #[account(mut)] + // Bound to the contributor: since anyone may send a refund, the tokens + // must go to a token account the contributor owns, in the raised mint. + #[account( + mut, + token(mint = mint_to_raise, authority = contributor, token_program = token_program), + )] pub contributor_ta: Account, #[account(mut)] @@ -72,6 +81,12 @@ pub fn handle_refund( .ok_or(FundraiserError::MathOverflow)?, ); accounts.contributor_account.amount = PodU64::from(0); + let open_contributor_accounts: u32 = accounts.fundraiser.open_contributor_accounts.into(); + accounts.fundraiser.open_contributor_accounts = PodU32::from( + open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?, + ); // Fundraiser PDA signer seeds: ["fundraiser", maker, bump]. let bump = [bumps.fundraiser]; diff --git a/finance/fundraiser/quasar/src/lib.rs b/finance/fundraiser/quasar/src/lib.rs index 0a18f1697..f5106752b 100644 --- a/finance/fundraiser/quasar/src/lib.rs +++ b/finance/fundraiser/quasar/src/lib.rs @@ -14,6 +14,7 @@ declare_id!("Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC"); /// Token crowdfunding program: a maker creates a fundraiser targeting a specific /// SPL token. Contributors deposit tokens into a vault. If the target is met, /// the maker withdraws everything. If not, contributors can reclaim their funds. +/// Once every contributor account is closed, the maker closes the fundraiser. #[program] mod quasar_fundraiser { use super::*; @@ -43,7 +44,9 @@ mod quasar_fundraiser { instructions::handle_contribute(&mut ctx.accounts, amount, &ctx.bumps) } - /// Maker withdraws all funds once the target is met. + /// Maker withdraws all funds once the target is met, marking the + /// fundraiser claimed. The fundraiser and the vault stay open until every + /// contributor account is closed. #[instruction(discriminator = 2)] pub fn check_contributions( ctx: Ctx, @@ -51,20 +54,28 @@ mod quasar_fundraiser { instructions::handle_check_contributions(&mut ctx.accounts, &ctx.bumps) } - /// Contributors reclaim their tokens after the deadline if the target - /// was not met. + /// Return a contributor's tokens after the deadline if the target was not + /// met. Anyone may send it; the tokens and the rent go to the contributor. #[instruction(discriminator = 3)] pub fn refund(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_refund(&mut ctx.accounts, &ctx.bumps) } - /// A contributor closes their contributor account once the fundraiser is - /// gone, taking back its rent. A successful raise closes the fundraiser - /// without touching the contributor accounts, so this is their exit. + /// Close a contributor account once its fundraiser has been claimed, + /// returning the rent to the contributor. Anyone may send it. #[instruction(discriminator = 4)] pub fn close_contributor( ctx: Ctx, ) -> Result<(), ProgramError> { instructions::handle_close_contributor(&mut ctx.accounts) } + + /// Maker closes a finished fundraiser and its vault once no contributor + /// account is open, so they can raise again at the same address. + #[instruction(discriminator = 5)] + pub fn close_fundraiser( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::handle_close_fundraiser(&mut ctx.accounts, &ctx.bumps) + } } diff --git a/finance/fundraiser/quasar/src/state.rs b/finance/fundraiser/quasar/src/state.rs index e3724cf08..a96f7d291 100644 --- a/finance/fundraiser/quasar/src/state.rs +++ b/finance/fundraiser/quasar/src/state.rs @@ -21,6 +21,14 @@ pub struct Fundraiser { pub time_started: i64, /// Fundraising window length in days, counted from `time_started`. pub duration: u16, + /// Set by `check_contributions`. A claimed fundraiser accepts no more + /// contributions and no second claim, and stays open until every + /// contributor account written for it has been closed. + pub claimed: PodBool, + /// How many contributor accounts written for this fundraiser are still + /// open. `close_fundraiser` requires zero, so a new fundraiser at the + /// same address never starts with contributor accounts from an old one. + pub open_contributor_accounts: u32, pub bump: u8, } diff --git a/finance/fundraiser/quasar/src/tests.rs b/finance/fundraiser/quasar/src/tests.rs index 43a5b4ce2..7d55d8fa0 100644 --- a/finance/fundraiser/quasar/src/tests.rs +++ b/finance/fundraiser/quasar/src/tests.rs @@ -1,12 +1,14 @@ //! quasar-test integration tests: create a fundraiser, contribute inside the -//! window, refund after a failed raise, and pay the maker after a successful -//! one — plus the deadline, target, and account-binding guard rails. +//! window, refund after a failed raise, pay the maker after a successful one, +//! close every contributor account and then the fundraiser, and raise again +//! at the same address — plus the deadline, target, claim, and +//! account-binding guard rails. use { crate::{ cpi::{ - CheckContributionsInstruction, CloseContributorInstruction, ContributeInstruction, - InitializeFundraiserInstruction, RefundInstruction, + CheckContributionsInstruction, CloseContributorInstruction, CloseFundraiserInstruction, + ContributeInstruction, InitializeFundraiserInstruction, RefundInstruction, }, error::FundraiserError, state::{Contributor, Fundraiser, SECONDS_PER_DAY}, @@ -28,6 +30,12 @@ const DEADLINE: i64 = START_TIME + DURATION_DAYS as i64 * SECONDS_PER_DAY; const CONTRIBUTOR_STARTING_BALANCE: u64 = 100_000; /// A contribution below the target, used by the refund-path tests. const PARTIAL_CONTRIBUTION: u64 = 500; +/// A second, different contribution below the target. +const SECOND_PARTIAL_CONTRIBUTION: u64 = 1_300; +/// Three unequal contributions that sum to exactly the target. +const CONTRIBUTIONS_REACHING_TARGET: [u64; 3] = [4_700, 3_100, 2_200]; +/// Tokens sent straight to the vault, outside `contribute`. +const DONATION: u64 = 750; // Deterministic addresses. const MAKER: Pubkey = Pubkey::new_from_array([1; 32]); @@ -40,14 +48,55 @@ const ATTACKER: Pubkey = Pubkey::new_from_array([7; 32]); const ATTACKER_TA: Pubkey = Pubkey::new_from_array([8; 32]); const DECOY_VAULT: Pubkey = Pubkey::new_from_array([9; 32]); +/// A contributor's wallet and their token account in the raised mint. +#[derive(Clone, Copy)] +struct ContributorKeys { + wallet: Pubkey, + token_account: Pubkey, +} + +const FIRST_CONTRIBUTOR: ContributorKeys = ContributorKeys { + wallet: CONTRIBUTOR, + token_account: CONTRIBUTOR_TA, +}; +const SECOND_CONTRIBUTOR: ContributorKeys = ContributorKeys { + wallet: Pubkey::new_from_array([10; 32]), + token_account: Pubkey::new_from_array([11; 32]), +}; +const THIRD_CONTRIBUTOR: ContributorKeys = ContributorKeys { + wallet: Pubkey::new_from_array([12; 32]), + token_account: Pubkey::new_from_array([13; 32]), +}; +/// Arrives after the claim. +const LATE_CONTRIBUTOR: ContributorKeys = ContributorKeys { + wallet: Pubkey::new_from_array([14; 32]), + token_account: Pubkey::new_from_array([15; 32]), +}; +/// Contributors to a second raise at the same fundraiser address. +const NEXT_RAISE_CONTRIBUTORS: [ContributorKeys; 2] = [ + ContributorKeys { + wallet: Pubkey::new_from_array([16; 32]), + token_account: Pubkey::new_from_array([17; 32]), + }, + ContributorKeys { + wallet: Pubkey::new_from_array([18; 32]), + token_account: Pubkey::new_from_array([19; 32]), + }, +]; +/// The contributors whose `CONTRIBUTIONS_REACHING_TARGET` fund a raise. +const TARGET_CONTRIBUTORS: [ContributorKeys; 3] = + [FIRST_CONTRIBUTOR, SECOND_CONTRIBUTOR, THIRD_CONTRIBUTOR]; + fn framework_error(error: QuasarError) -> ProgramError { ProgramError::Custom(error as u32) } -/// Register the maker, the mint, and warp to the fixed start time. +/// Register the maker, the maker's token account, the mint, and warp to the +/// fixed start time. fn base_world(test: &mut Test) { test.add(Wallet::new().at(MAKER)); test.add(Mint::new(MAKER).at(MINT).supply(1_000_000_000).decimals(9)); + test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); test.warp_to_timestamp(START_TIME); } @@ -61,38 +110,73 @@ fn initialize_fundraiser(test: &mut Test, amount_to_raise: u64, duration: u16) - }) } +/// Give a contributor a wallet and a funded token account. +fn add_contributor(test: &mut Test, contributor: ContributorKeys) { + test.add(Wallet::new().at(contributor.wallet)); + test.add( + TokenAccount::new(MINT, contributor.wallet) + .at(contributor.token_account) + .amount(CONTRIBUTOR_STARTING_BALANCE), + ); +} + /// A world with an initialized fundraiser and a funded contributor. fn initialized_world(test: &mut Test) -> Pubkey { base_world(test); initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS).succeeds(); - test.add(Wallet::new().at(CONTRIBUTOR)); - test.add( - TokenAccount::new(MINT, CONTRIBUTOR) - .at(CONTRIBUTOR_TA) - .amount(CONTRIBUTOR_STARTING_BALANCE), - ); + add_contributor(test, FIRST_CONTRIBUTOR); test.derive_pda(Fundraiser::seeds(&MAKER)) } -fn contribute(test: &mut Test, amount: u64) -> Outcome { +fn contributor_account(test: &Test, fundraiser: Pubkey, contributor: ContributorKeys) -> Pubkey { + test.derive_pda(Contributor::seeds(&fundraiser, &contributor.wallet)) +} + +fn contribute_from(test: &mut Test, contributor: ContributorKeys, amount: u64) -> Outcome { test.send(ContributeInstruction { - contributor: CONTRIBUTOR, + contributor: contributor.wallet, maker: MAKER, - contributor_ta: CONTRIBUTOR_TA, + contributor_ta: contributor.token_account, vault: VAULT, mint_to_raise: MINT, amount, }) } -fn refund(test: &mut Test) -> Outcome { - test.send(RefundInstruction { - contributor: CONTRIBUTOR, +fn contribute(test: &mut Test, amount: u64) -> Outcome { + contribute_from(test, FIRST_CONTRIBUTOR, amount) +} + +/// Three contributors whose contributions reach the target exactly. +fn fund_to_target(test: &mut Test) { + for (contributor, amount) in TARGET_CONTRIBUTORS + .iter() + .zip(CONTRIBUTIONS_REACHING_TARGET) + { + if contributor.wallet != CONTRIBUTOR { + add_contributor(test, *contributor); + } + contribute_from(test, *contributor, amount).succeeds(); + } +} + +fn refund_instruction(contributor: ContributorKeys) -> Instruction { + RefundInstruction { + contributor: contributor.wallet, maker: MAKER, - contributor_ta: CONTRIBUTOR_TA, + contributor_ta: contributor.token_account, vault: VAULT, mint_to_raise: MINT, - }) + } + .into() +} + +fn refund_for(test: &mut Test, contributor: ContributorKeys) -> Outcome { + test.send(refund_instruction(contributor)) +} + +fn refund(test: &mut Test) -> Outcome { + refund_for(test, FIRST_CONTRIBUTOR) } fn check_contributions(test: &mut Test) -> Outcome { @@ -104,13 +188,57 @@ fn check_contributions(test: &mut Test) -> Outcome { }) } -fn close_contributor(test: &mut Test, fundraiser: Pubkey) -> Outcome { - test.send(CloseContributorInstruction { - contributor: CONTRIBUTOR, +fn close_contributor_instruction(contributor: ContributorKeys, fundraiser: Pubkey) -> Instruction { + CloseContributorInstruction { + contributor: contributor.wallet, fundraiser, + } + .into() +} + +fn close_contributor_for( + test: &mut Test, + contributor: ContributorKeys, + fundraiser: Pubkey, +) -> Outcome { + test.send(close_contributor_instruction(contributor, fundraiser)) +} + +fn close_contributor(test: &mut Test, fundraiser: Pubkey) -> Outcome { + close_contributor_for(test, FIRST_CONTRIBUTOR, fundraiser) +} + +fn close_fundraiser(test: &mut Test) -> Outcome { + test.send(CloseFundraiserInstruction { + maker: MAKER, + vault: VAULT, + maker_ta: MAKER_TA, + mint_to_raise: MINT, }) } +/// Send tokens straight to the vault, bypassing `contribute`, by rewriting +/// the vault's balance. +fn donate_to_vault(test: &mut Test, fundraiser: Pubkey, amount: u64) { + let balance = test.tokens(VAULT); + test.add( + TokenAccount::new(MINT, fundraiser) + .at(VAULT) + .amount(balance + amount), + ); +} + +fn open_contributor_accounts(test: &Test, fundraiser: Pubkey) -> u32 { + u32::from( + test.read::(fundraiser) + .open_contributor_accounts, + ) +} + +fn is_claimed(test: &Test, fundraiser: Pubkey) -> bool { + bool::from(test.read::(fundraiser).claimed) +} + #[quasar_test] fn initialize_records_state_and_clock_time(test: &mut Test) { base_world(test); @@ -127,6 +255,8 @@ fn initialize_records_state_and_clock_time(test: &mut Test) { assert_eq!(u64::from(state.current_amount), 0); assert_eq!(i64::from(state.time_started), START_TIME); assert_eq!(u16::from(state.duration), DURATION_DAYS); + assert!(!bool::from(state.claimed)); + assert_eq!(u32::from(state.open_contributor_accounts), 0); assert_eq!(state.bump, expected_bump); } @@ -159,6 +289,7 @@ fn contribute_creates_contributor_account_and_moves_tokens(test: &mut Test) { u64::from(fundraiser_state.current_amount), PARTIAL_CONTRIBUTION ); + assert_eq!(u32::from(fundraiser_state.open_contributor_accounts), 1); let (contributor_account, expected_bump) = test.derive_pda_with_bump(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); @@ -168,17 +299,18 @@ fn contribute_creates_contributor_account_and_moves_tokens(test: &mut Test) { } #[quasar_test] -fn contribute_accumulates_across_calls(test: &mut Test) { +fn contributions_accumulate_in_one_contributor_account(test: &mut Test) { let fundraiser = initialized_world(test); contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - // Second contribution reuses the contributor account created by the first. - let expected_total = PARTIAL_CONTRIBUTION * 2; - contribute(test, PARTIAL_CONTRIBUTION) + // The second contribution reuses the contributor account created by the + // first, so the fundraiser still counts one open contributor account. + let expected_total = PARTIAL_CONTRIBUTION + SECOND_PARTIAL_CONTRIBUTION; + contribute(test, SECOND_PARTIAL_CONTRIBUTION) .succeeds() .has_tokens(VAULT, expected_total); - let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); assert_eq!( u64::from(test.read::(contributor_account).amount), expected_total @@ -187,6 +319,7 @@ fn contribute_accumulates_across_calls(test: &mut Test) { u64::from(test.read::(fundraiser).current_amount), expected_total ); + assert_eq!(open_contributor_accounts(test, fundraiser), 1); } #[quasar_test] @@ -230,6 +363,28 @@ fn contribute_rejects_vault_not_bound_to_fundraiser(test: &mut Test) { .fails(framework_error(QuasarError::HasOneMismatch)); } +#[quasar_test] +fn contribute_after_claim_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // The deadline is still days away, but the vault has been paid out. + test.warp_to_timestamp(START_TIME + SECONDS_PER_DAY); + add_contributor(test, LATE_CONTRIBUTOR); + contribute_from(test, LATE_CONTRIBUTOR, PARTIAL_CONTRIBUTION) + .fails_with(FundraiserError::FundraiserClaimed); + + assert_eq!( + test.tokens(LATE_CONTRIBUTOR.token_account), + CONTRIBUTOR_STARTING_BALANCE + ); + assert_eq!( + open_contributor_accounts(test, fundraiser), + TARGET_CONTRIBUTORS.len() as u32 + ); +} + #[quasar_test] fn refund_returns_tokens_after_failed_fundraiser(test: &mut Test) { let fundraiser = initialized_world(test); @@ -237,7 +392,7 @@ fn refund_returns_tokens_after_failed_fundraiser(test: &mut Test) { test.warp_to_timestamp(DEADLINE); - let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); refund(test) .succeeds() .has_tokens(VAULT, 0) @@ -245,9 +400,36 @@ fn refund_returns_tokens_after_failed_fundraiser(test: &mut Test) { // The contributor account was closed and its rent returned. .is_closed(contributor_account); + let fundraiser_state = test.read::(fundraiser); + assert_eq!(u64::from(fundraiser_state.current_amount), 0); + assert_eq!(u32::from(fundraiser_state.open_contributor_accounts), 0); +} + +#[quasar_test] +fn anyone_can_refund_a_contributor(test: &mut Test) { + let fundraiser = initialized_world(test); + contribute(test, SECOND_PARTIAL_CONTRIBUTION).succeeds(); + test.warp_to_timestamp(DEADLINE); + + // No account in the refund is a signer: whoever sends it, the tokens + // and the rent go to the contributor, who signs nothing. + let instruction = refund_instruction(FIRST_CONTRIBUTOR); + assert!( + instruction.accounts.iter().all(|meta| !meta.is_signer), + "refund must not require any signature" + ); + + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); + let rent = test.lamports(contributor_account); + let contributor_lamports_before = test.lamports(CONTRIBUTOR); + + test.send(instruction) + .succeeds() + .has_tokens(CONTRIBUTOR_TA, CONTRIBUTOR_STARTING_BALANCE) + .is_closed(contributor_account); assert_eq!( - u64::from(test.read::(fundraiser).current_amount), - 0 + test.lamports(CONTRIBUTOR), + contributor_lamports_before + rent ); } @@ -263,10 +445,11 @@ fn refund_rejected_before_deadline(test: &mut Test) { #[quasar_test] fn refund_rejected_when_target_met(test: &mut Test) { initialized_world(test); - contribute(test, TARGET_AMOUNT).succeeds(); + fund_to_target(test); test.warp_to_timestamp(DEADLINE); refund(test).fails_with(FundraiserError::TargetMet); + assert_eq!(test.tokens(VAULT), TARGET_AMOUNT); } #[quasar_test] @@ -276,46 +459,55 @@ fn refund_rejects_another_contributors_account(test: &mut Test) { test.warp_to_timestamp(DEADLINE); - // The attacker signs as themselves but passes the victim's contributor - // record and their own token account, trying to drain the vault. test.add(Wallet::new().at(ATTACKER)); test.add(TokenAccount::new(MINT, ATTACKER).at(ATTACKER_TA)); - let mut instruction: Instruction = RefundInstruction { - contributor: CONTRIBUTOR, - maker: MAKER, - contributor_ta: ATTACKER_TA, - vault: VAULT, - mint_to_raise: MINT, - } - .into(); + // Refunds need no signature, so the attacker names the victim as the + // contributor but routes the tokens to their own token account. The + // destination must be owned by the contributor. + let mut instruction = refund_instruction(FIRST_CONTRIBUTOR); // Account indices follow the accounts-struct field order: - // 0 contributor (signer), 3 contributor_account, 4 contributor_ta. The - // builder derived contributor_account for the VICTIM; swap only the - // signer to the attacker. - instruction.accounts[0].pubkey = ATTACKER; + // 0 contributor, 3 contributor_account, 4 contributor_ta. + instruction.accounts[4].pubkey = ATTACKER_TA; + test.send(instruction) + .fails(ProgramError::InvalidAccountData); - // The contributor_account PDA check derives ["contributor", fundraiser, - // attacker], which does not match the victim's record. + // The attacker names themselves as the contributor, with their own token + // account, against the victim's contributor record. The record's PDA is + // derived from ["contributor", fundraiser, attacker], which does not + // match. + let mut instruction = refund_instruction(FIRST_CONTRIBUTOR); + instruction.accounts[0].pubkey = ATTACKER; + instruction.accounts[4].pubkey = ATTACKER_TA; test.send(instruction) .fails(framework_error(QuasarError::InvalidPda)); + // The vault still holds the victim's contribution. assert_eq!(test.tokens(VAULT), PARTIAL_CONTRIBUTION); + assert_eq!(test.tokens(ATTACKER_TA), 0); } #[quasar_test] -fn check_contributions_pays_maker_when_target_met(test: &mut Test) { +fn check_contributions_pays_maker_and_marks_claimed(test: &mut Test) { let fundraiser = initialized_world(test); - contribute(test, TARGET_AMOUNT).succeeds(); - - test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); + fund_to_target(test); check_contributions(test) .succeeds() .has_tokens(MAKER_TA, TARGET_AMOUNT) - // The vault and fundraiser accounts were closed. - .is_closed(VAULT) - .is_closed(fundraiser); + .has_tokens(VAULT, 0); + + // The fundraiser and the vault stay open, the fundraiser marked claimed, + // until every contributor account written for it is closed. + assert!( + test.account(VAULT).is_some(), + "the vault survives the claim" + ); + assert!(is_claimed(test, fundraiser)); + assert_eq!( + open_contributor_accounts(test, fundraiser), + TARGET_CONTRIBUTORS.len() as u32 + ); } #[quasar_test] @@ -323,21 +515,44 @@ fn check_contributions_rejected_below_target(test: &mut Test) { initialized_world(test); contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); check_contributions(test).fails_with(FundraiserError::TargetNotMet); } #[quasar_test] -fn close_contributor_returns_rent_after_successful_raise(test: &mut Test) { +fn check_contributions_ignores_direct_vault_donations(test: &mut Test) { let fundraiser = initialized_world(test); - contribute(test, TARGET_AMOUNT).succeeds(); - test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); - check_contributions(test).succeeds().is_closed(fundraiser); + // The full target sent straight to the vault leaves the state-tracked + // current_amount at 0, so the claim must fail. + donate_to_vault(test, fundraiser, TARGET_AMOUNT); + + check_contributions(test).fails_with(FundraiserError::TargetNotMet); + assert!(!is_claimed(test, fundraiser)); +} - // The claim closed the fundraiser and the vault, but the contributor - // account is still open with its rent inside. - let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); +#[quasar_test] +fn second_claim_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // A donation to the vault after the claim must not make a second claim + // possible. + donate_to_vault(test, fundraiser, DONATION); + + check_contributions(test).fails_with(FundraiserError::FundraiserClaimed); + assert_eq!(test.tokens(MAKER_TA), TARGET_AMOUNT); + assert_eq!(test.tokens(VAULT), DONATION); +} + +#[quasar_test] +fn close_contributor_returns_rent_after_successful_raise(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // The claim leaves the contributor account open with its rent inside. + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); let rent = test.lamports(contributor_account); assert!(rent > 0, "the contributor account survives the claim"); let lamports_before = test.lamports(CONTRIBUTOR); @@ -350,20 +565,234 @@ fn close_contributor_returns_rent_after_successful_raise(test: &mut Test) { lamports_before + rent, "the contributor account's rent returns to the contributor" ); + assert_eq!( + open_contributor_accounts(test, fundraiser), + TARGET_CONTRIBUTORS.len() as u32 - 1 + ); } #[quasar_test] -fn close_contributor_rejected_while_fundraiser_exists(test: &mut Test) { +fn anyone_can_close_contributor_accounts_after_claim(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // Whoever sends it, each rent deposit goes to its contributor, who signs + // nothing. + for contributor in TARGET_CONTRIBUTORS { + let instruction = close_contributor_instruction(contributor, fundraiser); + assert!( + instruction.accounts.iter().all(|meta| !meta.is_signer), + "close_contributor must not require any signature" + ); + + let contributor_account = contributor_account(test, fundraiser, contributor); + let rent = test.lamports(contributor_account); + let lamports_before = test.lamports(contributor.wallet); + test.send(instruction) + .succeeds() + .is_closed(contributor_account); + assert_eq!(test.lamports(contributor.wallet), lamports_before + rent); + } + + assert_eq!(open_contributor_accounts(test, fundraiser), 0); +} + +#[quasar_test] +fn close_contributor_before_claim_fails(test: &mut Test) { let fundraiser = initialized_world(test); contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - // The fundraiser is live, so the contribution is live too: closing the - // record now would erase what the vault owes this contributor. - close_contributor(test, fundraiser).fails_with(FundraiserError::FundraiserStillOpen); + // The fundraiser is unclaimed, so the contribution can still be + // refunded: closing the record now would erase what the vault owes. + close_contributor(test, fundraiser).fails_with(FundraiserError::FundraiserNotClaimed); - let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); assert_eq!( u64::from(test.read::(contributor_account).amount), PARTIAL_CONTRIBUTION ); + assert_eq!(open_contributor_accounts(test, fundraiser), 1); +} + +#[quasar_test] +fn close_fundraiser_after_failed_raise_allows_a_new_raise(test: &mut Test) { + let fundraiser = initialized_world(test); + contribute(test, PARTIAL_CONTRIBUTION).succeeds(); + + // The raise fails; the contributor takes their refund. + test.warp_to_timestamp(DEADLINE); + refund(test).succeeds(); + + close_fundraiser(test) + .succeeds() + .is_closed(VAULT) + .is_closed(fundraiser); + + // The same maker can now open a fresh fundraiser at the same address. + initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS).succeeds(); + let state = test.read::(fundraiser); + assert_eq!(u64::from(state.current_amount), 0); + assert_eq!(u64::from(state.amount_to_raise), TARGET_AMOUNT); + assert_eq!(i64::from(state.time_started), DEADLINE); + assert_eq!(test.tokens(VAULT), 0); +} + +#[quasar_test] +fn close_fundraiser_after_claim_allows_a_new_raise(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + for contributor in TARGET_CONTRIBUTORS { + close_contributor_for(test, contributor, fundraiser).succeeds(); + } + // A claimed fundraiser closes without waiting for its deadline. + close_fundraiser(test) + .succeeds() + .is_closed(VAULT) + .is_closed(fundraiser); + + initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS).succeeds(); + assert!(!is_claimed(test, fundraiser)); + assert_eq!( + u64::from(test.read::(fundraiser).current_amount), + 0 + ); +} + +#[quasar_test] +fn close_fundraiser_before_deadline_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + + // One second short of the deadline. + test.warp_to_timestamp(DEADLINE - 1); + + close_fundraiser(test).fails_with(FundraiserError::FundraiserNotEnded); + assert!(test.account(fundraiser).is_some()); +} + +#[quasar_test] +fn close_fundraiser_with_unrefunded_contributions_fails(test: &mut Test) { + initialized_world(test); + contribute(test, PARTIAL_CONTRIBUTION).succeeds(); + + // Past the deadline but the contribution has not been refunded, so + // closing would strand it in the vault. + test.warp_to_timestamp(DEADLINE); + + close_fundraiser(test).fails_with(FundraiserError::RefundsOutstanding); + assert_eq!(test.tokens(VAULT), PARTIAL_CONTRIBUTION); +} + +#[quasar_test] +fn close_fundraiser_when_target_met_but_unclaimed_fails(test: &mut Test) { + initialized_world(test); + fund_to_target(test); + + test.warp_to_timestamp(DEADLINE); + + // A raise that met its target closes only after the maker claims it. + close_fundraiser(test).fails_with(FundraiserError::TargetMet); + assert_eq!(test.tokens(VAULT), TARGET_AMOUNT); +} + +#[quasar_test] +fn close_fundraiser_with_open_contributor_accounts_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // Close all but the first contributor account. + for contributor in &TARGET_CONTRIBUTORS[1..] { + close_contributor_for(test, *contributor, fundraiser).succeeds(); + } + + close_fundraiser(test).fails_with(FundraiserError::ContributorAccountsOpen); + assert!(test.account(fundraiser).is_some()); + assert_eq!(open_contributor_accounts(test, fundraiser), 1); +} + +#[quasar_test] +fn reinitialize_with_open_contributor_accounts_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // The claimed fundraiser account still exists, so a new fundraiser + // cannot be initialized at its address. + initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS) + .fails(ProgramError::AccountAlreadyInitialized); + assert!(is_claimed(test, fundraiser)); + assert_eq!( + open_contributor_accounts(test, fundraiser), + TARGET_CONTRIBUTORS.len() as u32 + ); +} + +#[quasar_test] +fn stale_contributor_account_cannot_refund_from_next_raise(test: &mut Test) { + let fundraiser = initialized_world(test); + + // Raise one succeeds and the maker claims it. + fund_to_target(test); + check_contributions(test).succeeds(); + + // The maker closes every contributor account from raise one, then the + // fundraiser, and starts raise two at the same address. + for contributor in TARGET_CONTRIBUTORS { + close_contributor_for(test, contributor, fundraiser).succeeds(); + } + close_fundraiser(test).succeeds(); + initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS).succeeds(); + + // Raise two collects less than the target and fails. + let next_raise_amounts = [PARTIAL_CONTRIBUTION, SECOND_PARTIAL_CONTRIBUTION]; + for (contributor, amount) in NEXT_RAISE_CONTRIBUTORS.iter().zip(next_raise_amounts) { + add_contributor(test, *contributor); + contribute_from(test, *contributor, amount).succeeds(); + } + let raise_two_total = PARTIAL_CONTRIBUTION + SECOND_PARTIAL_CONTRIBUTION; + assert_eq!(open_contributor_accounts(test, fundraiser), 2); + test.warp_to_timestamp(DEADLINE); + + // A raise-one contributor tries to take a refund from raise two. Their + // contributor account was closed with raise one, so the address is a + // system-owned empty account, not a contributor record: there is nothing + // to refund. + let stale_contributor = FIRST_CONTRIBUTOR; + let stale_balance = test.tokens(stale_contributor.token_account); + // The runtime reports the wrong-owner check as `IllegalOwner`, outside + // quasar-test's stable error set. + refund_for(test, stale_contributor).fails(ProgramError::Runtime("IllegalOwner".into())); + assert_eq!(test.tokens(VAULT), raise_two_total); + assert_eq!(test.tokens(stale_contributor.token_account), stale_balance); + + // Every raise-two contributor is refunded in full. + for contributor in NEXT_RAISE_CONTRIBUTORS { + refund_for(test, contributor) + .succeeds() + .has_tokens(contributor.token_account, CONTRIBUTOR_STARTING_BALANCE); + } + let state = test.read::(fundraiser); + assert_eq!(u64::from(state.current_amount), 0); + assert_eq!(u32::from(state.open_contributor_accounts), 0); + assert_eq!(test.tokens(VAULT), 0); +} + +#[quasar_test] +fn close_fundraiser_sweeps_direct_donations_to_maker(test: &mut Test) { + let fundraiser = initialized_world(test); + + // Tokens sent straight to the vault are outside the program's + // accounting; on close they go to the maker instead of being burned + // with the account. + donate_to_vault(test, fundraiser, DONATION); + + test.warp_to_timestamp(DEADLINE); + close_fundraiser(test) + .succeeds() + .has_tokens(MAKER_TA, DONATION) + .is_closed(VAULT) + .is_closed(fundraiser); } From e4861127274496998afbfad5eab09025a9b0edde Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 19:45:06 +0000 Subject: [PATCH 3/5] fundraiser (anchor-v1): keep the fundraiser until its receipts close; drop the per-backer cap Port of the Anchor v2 fix, same semantics, errors and 26 tests. The README now names the handler initialize_fundraiser, which it is. Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- finance/fundraiser/anchor-v1/CHANGELOG.md | 17 + finance/fundraiser/anchor-v1/README.md | 68 +- .../programs/fundraiser/src/constants.rs | 2 - .../programs/fundraiser/src/error.rs | 12 +- .../fundraiser/src/instructions/checker.rs | 38 +- .../fundraiser/src/instructions/close.rs | 69 +- .../src/instructions/close_contributor.rs | 48 +- .../fundraiser/src/instructions/contribute.rs | 37 +- .../src/instructions/initialize_fundraiser.rs | 2 + .../fundraiser/src/instructions/refund.rs | 11 +- .../anchor-v1/programs/fundraiser/src/lib.rs | 2 +- .../fundraiser/src/state/fundraiser.rs | 8 + .../fundraiser/tests/test_fundraiser.rs | 1084 ++++++++--------- 13 files changed, 717 insertions(+), 681 deletions(-) diff --git a/finance/fundraiser/anchor-v1/CHANGELOG.md b/finance/fundraiser/anchor-v1/CHANGELOG.md index c18c05f0d..912e35d7a 100644 --- a/finance/fundraiser/anchor-v1/CHANGELOG.md +++ b/finance/fundraiser/anchor-v1/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2026-10-01 + +### Fixed + +- **A contributor account could outlive its fundraiser and count toward the next one.** `check_contributions` closed the Fundraiser account while the Contributor accounts, derived from its address, stayed open. The maker could then initialize a new fundraiser at the same address, and a leftover Contributor account would count as a contribution to it: `refund` would pay its old amount out of the new contributors' tokens. `check_contributions` now pays out the vault and sets a new `claimed` flag instead of closing anything, and the Fundraiser keeps a new `open_contributor_accounts` count. `close_fundraiser` closes a claimed fundraiser once that count is zero (else the new `ContributorAccountsOpen` error), so no Contributor account survives into the next raise. `test_stale_contributor_account_cannot_refund_from_next_raise`, `test_reinitialize_with_open_contributor_accounts_fails` and `test_close_fundraiser_with_open_contributor_accounts_fails` cover it. + +### Changed + +- `close_contributor` requires the fundraiser to be claimed (`FundraiserNotClaimed`, replacing `FundraiserStillOpen`) rather than gone, and decrements `open_contributor_accounts`. +- `refund` and `close_contributor` no longer require the contributor's signature. The tokens and rent still go only to the contributor, and anyone can send either, so the maker can refund or close every Contributor account without waiting on any contributor. +- `contribute` and `check_contributions` refuse a claimed fundraiser with the new `FundraiserClaimed` error. +- Program errors are public (`pub use error::*`) so the tests assert each failure's specific error code. + +### Removed + +- The per-contributor cap (`MAX_CONTRIBUTION_PERCENTAGE`, `PERCENTAGE_SCALER`, and the `ContributionTooBig` and `MaximumContributionsReached` errors). It limited each wallet, and a wallet costs nothing to create, so it did not stop one person funding most of a raise. + ## 2026-09-28 - **Renamed from Token Fundraiser to Fundraiser.** The example moved from `finance/token-fundraiser` to `finance/fundraiser`: contributors receive no token, only a refund if the target is missed, so "Token" described something the program does not do. The program, its accounts, its instruction handlers and its tests are unchanged. diff --git a/finance/fundraiser/anchor-v1/README.md b/finance/fundraiser/anchor-v1/README.md index 971a4d619..946c0f8a8 100644 --- a/finance/fundraiser/anchor-v1/README.md +++ b/finance/fundraiser/anchor-v1/README.md @@ -6,7 +6,7 @@ > `avm install 1.2.0 && avm use 1.2.0`. The Anchor v2 version of this example is in > [`../anchor`](../anchor/). -Onchain crowdfunding on Solana: a program that collects tokens toward a target amount, like Kickstarter without a payment processor. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds and each contributor closes their own record to take back its rent; if it is not reached by the deadline, contributors can refund, and once refunds are complete the maker can retire the fundraiser and open a new one. +Onchain crowdfunding on Solana: a program that collects tokens toward a target amount, like Kickstarter without a payment processor. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds, anyone closes each contributor's record to return its rent to that contributor, and the maker then closes the fundraiser; if it is not reached by the deadline, anyone can refund each contributor, and once refunds are complete the maker closes the fundraiser. Either way, the maker can then open a new one. This example was called **Token Fundraiser** (`finance/token-fundraiser`) until it was renamed: contributors receive no token, only a refund if the target is missed. @@ -24,6 +24,8 @@ pub struct Fundraiser { pub current_amount: u64, pub time_started: i64, pub duration: u16, + pub claimed: bool, + pub open_contributor_accounts: u32, pub bump: u8, } ``` @@ -36,6 +38,8 @@ Fields: - `current_amount` - total amount contributed through the `contribute` handler. This tracked total, not the vault balance, is what `check_contributions` and `refund` compare against the target, so tokens sent directly to the vault cannot trigger an early release or block refunds. - `time_started` - when the fundraiser was created. - `duration` - fundraising window in days. +- `claimed` - set by `check_contributions`. A claimed fundraiser accepts no more contributions and no second claim. +- `open_contributor_accounts` - how many Contributor accounts written for this fundraiser are still open. `contribute` adds one when it creates a Contributor account; `refund` and `close_contributor` each subtract one when they close one. `close_fundraiser` requires zero. - `bump` - canonical bump for the Fundraiser [PDA](https://solana.com/docs/terminology#program-derived-address-pda). The `InitSpace` derive macro implements the `Space` trait, which calculates the size of the account (not counting the [Anchor](https://solana.com/docs/terminology#anchor) discriminator). @@ -54,7 +58,7 @@ pub struct Contributor { - `amount` - total amount contributed by this contributor. - `bump` - canonical bump for the Contributor PDA. -The Contributor PDA uses `init_if_needed`, which only runs the init branch on first call. The handler stores `bumps.contributor_account` into `bump` on first init (when `bump == 0`); see [`instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs). +The Contributor PDA uses `init_if_needed`, which only runs the init branch on first call. On first init (when `bump == 0`) the handler stores `bumps.contributor_account` into `bump` and adds one to `open_contributor_accounts`; see [`instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs). ### Constants @@ -63,11 +67,9 @@ From [`constants.rs`](programs/fundraiser/src/constants.rs): ```rust pub const MIN_AMOUNT_TO_RAISE: u64 = 3; pub const SECONDS_TO_DAYS: i64 = 86400; -pub const MAX_CONTRIBUTION_PERCENTAGE: u64 = 10; -pub const PERCENTAGE_SCALER: u64 = 100; ``` -`MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` = 10%, the per-contributor cap. `MIN_AMOUNT_TO_RAISE` is the minimum target in major units. +`MIN_AMOUNT_TO_RAISE` is the minimum target in major units. ### Code layout @@ -79,75 +81,75 @@ All token accounts use `anchor_spl::token_interface` types (`InterfaceAccount= MIN_AMOUNT_TO_RAISE * 10^decimals` (the target must be at least 3 major units of the mint, expressed in minor units), then initializes the Fundraiser state with `current_amount = 0` and `time_started` from the `Clock` sysvar. A target below the minimum fails with `InvalidAmount`. +The handler requires `amount >= MIN_AMOUNT_TO_RAISE * 10^decimals` (the target must be at least 3 major units of the mint, expressed in minor units), then initializes the Fundraiser state with `current_amount = 0`, `claimed = false`, `open_contributor_accounts = 0`, and `time_started` from the `Clock` sysvar. A target below the minimum fails with `InvalidAmount`. ### `contribute` [`programs/fundraiser/src/instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs), account constraints `ContributeAccountConstraints`. -A contributor signs and the handler performs four checks in order: +A contributor signs and the handler performs three checks in order: 1. Minimum contribution: `amount >= 10^decimals` (one major unit of the mint), else `ContributionTooSmall`. -2. Per-call cap: `amount <= amount_to_raise * MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` (10% of the target), else `ContributionTooBig`. +2. Not yet claimed: `!claimed`, else `FundraiserClaimed`. The deadline may still be days away after a claim, and the vault has already been paid out. 3. Time window: contributions are allowed while `elapsed_days < duration`, where `elapsed_days = (now - time_started) / SECONDS_TO_DAYS`. Once `elapsed_days` reaches `duration` the handler fails with `FundraiserEnded`. -4. Cumulative cap: the contributor's running total (existing + new) must not exceed the same 10% cap, else `MaximumContributionsReached`. -If all checks pass, `Fundraiser.current_amount` and `Contributor.amount` are updated, then `amount` is transferred from `contributor_ata` to `vault` with `transfer_checked`. +If all checks pass, `Fundraiser.current_amount` and `Contributor.amount` are updated (a contributor's later contributions add to the same Contributor account), then `amount` is transferred from `contributor_ata` to `vault` with `transfer_checked`. ### `check_contributions` [`programs/fundraiser/src/instructions/checker.rs`](programs/fundraiser/src/instructions/checker.rs), account constraints `CheckContributionsAccountConstraints`. -Lets the maker claim the funds once the target is met. Requires `fundraiser.current_amount >= amount_to_raise` (the state-tracked total, so direct donations to the vault cannot unlock the claim early), else `TargetNotMet`. The handler then, signing both CPIs with the Fundraiser PDA's seeds: +Lets the maker claim the funds once the target is met. Requires `!claimed`, else `FundraiserClaimed`, and `fundraiser.current_amount >= amount_to_raise` (the state-tracked total, so direct donations to the vault cannot unlock the claim early), else `TargetNotMet`. The handler sets `claimed` and transfers the entire vault balance (including any direct donations) to `maker_ata` with `transfer_checked`, signed with the Fundraiser PDA's seeds. -1. Transfers the entire vault balance (including any direct donations) to `maker_ata` with `transfer_checked`. -2. Closes the empty vault token account with `close_account`, returning its rent to the maker. - -The Fundraiser state account is closed via the `close = maker` constraint, so the maker also recovers that [rent](https://solana.com/docs/terminology#rent). +The Fundraiser account and the empty vault stay open. Contributor accounts are derived from the Fundraiser's address, so the Fundraiser must outlive every one of them: if it closed here, the maker could initialize a new fundraiser at the same address, and the Contributor accounts left over from this raise would count as contributions to the new one, so `refund` would pay their old amounts out of the new contributors' tokens. `close_contributor` closes the Contributor accounts, then `close_fundraiser` closes the Fundraiser and the vault. ### `refund` [`programs/fundraiser/src/instructions/refund.rs`](programs/fundraiser/src/instructions/refund.rs), account constraints `RefundAccountConstraints`. -Lets a contributor reclaim their contribution after a failed fundraiser. Two checks: +Returns a contribution after a failed fundraiser. The contributor does not have to sign: the tokens go to their token account and the rent to them, whoever sends the transaction, so the maker can refund every contributor and close a failed fundraiser without waiting on any of them. Two checks: 1. Refunds are allowed only after the fundraiser has ended: `elapsed_days >= duration`, else `FundraiserNotEnded`. 2. The target was not met: `fundraiser.current_amount < amount_to_raise` (again the state-tracked total, so donated tokens cannot block refunds), else `TargetMet`. -The handler subtracts the contributor's recorded amount from `current_amount` and zeroes the Contributor record before the transfer CPI, then sends the tokens from the vault back to `contributor_ata` with `transfer_checked` (PDA signer). The Contributor account is closed via `close = contributor`, refunding its rent to the contributor. +The handler subtracts the contributor's recorded amount from `current_amount` zeroes the Contributor record, and subtracts one from `open_contributor_accounts` before the transfer CPI, then sends the tokens from the vault back to `contributor_ata` with `transfer_checked` (PDA signer). The Contributor account is closed via `close = contributor`, refunding its rent to the contributor. ### `close_fundraiser` [`programs/fundraiser/src/instructions/close.rs`](programs/fundraiser/src/instructions/close.rs), account constraints `CloseFundraiserAccountConstraints`. -Retires a failed fundraiser so the maker can raise again. The Fundraiser PDA is derived from `b"fundraiser"` and the maker's public key alone, so while a failed fundraiser's account exists the maker can never initialize another one. Three checks: +Closes a finished fundraiser and its vault so the maker can raise again. The Fundraiser PDA is derived from `b"fundraiser"` and the maker's public key alone, so while a Fundraiser account exists the maker cannot initialize another one. + +For an unclaimed fundraiser, three checks: 1. The fundraiser has ended: `elapsed_days >= duration`, else `FundraiserNotEnded`. -2. The target was not met: `fundraiser.current_amount < amount_to_raise`, else `TargetMet` (a successful raise exits through `check_contributions`, which already closes these accounts). +2. The target was not met: `fundraiser.current_amount < amount_to_raise`, else `TargetMet` (a raise that met its target closes after the maker claims it). 3. Every contribution has been refunded: `fundraiser.current_amount == 0`, else `RefundsOutstanding` (closing the vault earlier would strand the remaining refunds). -Anything still in the vault at this point is a direct donation outside the program's accounting; the handler sweeps it to `maker_ata` with `transfer_checked` rather than burning it, then closes the vault with `close_account` (both CPIs signed with the Fundraiser PDA's seeds). The Fundraiser state account is closed via `close = maker`. +For every fundraiser, claimed or not: `open_contributor_accounts == 0`, else `ContributorAccountsOpen`. A Contributor account left open would be read as a contribution to the next fundraiser at this address. + +Anything still in the vault at this point is a direct donation outside the program's accounting; the handler pays it to `maker_ata` with `transfer_checked` rather than burning it, then closes the vault with `close_account` (both CPIs signed with the Fundraiser PDA's seeds). The Fundraiser state account is closed via `close = maker`. ### `close_contributor` [`programs/fundraiser/src/instructions/close_contributor.rs`](programs/fundraiser/src/instructions/close_contributor.rs), account constraints `CloseContributorAccountConstraints`. -Lets a contributor close their Contributor account once the fundraiser is gone, taking back its rent. A successful raise exits through `check_contributions`, which closes the vault and the Fundraiser account but cannot reach the Contributor accounts: there is one per contributor and the claim carries none of them. Their other closer, `refund`, runs only on a failed raise, so without this handler every contributor to a successful raise would hold their rent in an account nothing could close. +Closes a Contributor account once its fundraiser has been claimed, returning the rent to the contributor. On a successful raise the contribution has been paid out to the maker, so the account holds only rent, and `close_fundraiser` cannot run until every one of them is closed. -One check: the `fundraiser` account passed in is not owned by this program, else `FundraiserStillOpen`. A live fundraiser is program-owned; a closed one belongs to the system program again, whatever lamports it holds. The Contributor account's seeds bind it to that fundraiser address, so no other fundraiser can be substituted. The account is closed via `close = contributor`. +One check: `fundraiser.claimed`, else `FundraiserNotClaimed`. While the fundraiser is unclaimed the contribution can still be refunded, so `refund` is the way to close it. The contributor does not have to sign: the rent goes to them whoever sends the transaction, so the maker can close every Contributor account and then the fundraiser. The Contributor account's seeds bind it to the fundraiser's address, and the handler subtracts one from `open_contributor_accounts`. The account is closed via `close = contributor`. ## Testing @@ -158,22 +160,28 @@ cargo build-sbf cargo test ``` -The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. It exercises both contribution caps (a single contribution over the 10% cap, and contributions that cumulatively exceed it), and verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, that `close_fundraiser` retires a failed raise (only after the deadline, only when the target was missed, only once refunds are complete, sweeping direct donations to the maker) and lets the same maker initialize a fresh fundraiser, and that `close_contributor` returns a contributor's rent after a successful claim and is refused while the fundraiser exists. Assertions check token balances and decoded account state rather than just transaction success. +The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. Every failing case asserts the specific program error. + +It checks that the claim pays the maker and marks the fundraiser claimed, that a second claim and a contribution after the claim are refused, that direct vault donations do not unlock the claim, and that anyone can refund a contributor or close their Contributor account after a claim, with the tokens and rent going to the contributor. + +`test_stale_contributor_account_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contributor accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails while every second-raise contributor gets back exactly what they put in. `test_reinitialize_with_open_contributor_accounts_fails` and `test_close_fundraiser_with_open_contributor_accounts_fails` check that the second raise cannot start while any first-raise Contributor account is open. + +`close_fundraiser` is tested on both paths (after a failed raise, only after the deadline, only when the target was missed and refunds are complete; after a claim, only once every Contributor account is closed), including that it pays direct donations to the maker and that the same maker can then initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success. ## FAQ ### How do I build crowdfunding on Solana? -A maker opens a fundraiser with `initialize`, naming the token, target amount, and duration. Contributors deposit with `contribute` while the window is open, and the funds sit in a program-controlled vault that neither side can raid. When the target is reached, the maker claims the raise with `check_contributions`, which pays out the vault and closes the fundraiser. +A maker opens a fundraiser with `initialize_fundraiser`, naming the token, target amount, and duration. Contributors deposit with `contribute` while the window is open, and the funds sit in a program-controlled vault that neither side can raid. When the target is reached, the maker claims the raise with `check_contributions`, which pays out the vault and marks the fundraiser claimed. ### What happens to the contributor accounts after a successful raise? -The claim closes the vault and the Fundraiser account, but each Contributor account stays open with its rent inside. Its owner calls `close_contributor`, which checks that the fundraiser is gone and returns the rent. +Each stays open with its rent inside until someone calls `close_contributor`, which checks that the fundraiser has been claimed and returns the rent to the contributor. Anyone can send it, so the maker can close them all, then call `close_fundraiser` to close the Fundraiser account and the vault. ### What happens if the fundraiser misses its target? -Contributors call `refund` after the deadline to reclaim exactly what they put in. Once refunds are complete, the maker calls `close_fundraiser` to retire the failed raise and can then open a new one. +After the deadline, `refund` returns each contributor exactly what they put in; anyone can send it. Once refunds are complete, the maker calls `close_fundraiser` to retire the failed raise and can then open a new one. ### How is this fundraiser tested and verified? -`anchor build` then `cargo test` runs LiteSVM tests that warp the clock across the deadline to exercise contribution windows, per-contributor caps, claims, refunds, and closing. The arithmetic has [Kani](https://github.com/model-checking/kani) model checks in [`../kani-proofs/`](../kani-proofs/). +`anchor build` then `cargo test` runs LiteSVM tests that warp the clock across the deadline to exercise contribution windows, claims, refunds, and closing on both paths. The arithmetic has [Kani](https://github.com/model-checking/kani) model checks in [`../kani-proofs/`](../kani-proofs/). diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/constants.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/constants.rs index 2af19dd91..87e10ebc5 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/constants.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/constants.rs @@ -1,4 +1,2 @@ pub const MIN_AMOUNT_TO_RAISE: u64 = 3; pub const SECONDS_TO_DAYS: i64 = 86400; -pub const MAX_CONTRIBUTION_PERCENTAGE: u64 = 10; -pub const PERCENTAGE_SCALER: u64 = 100; diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs index 06e94d8a1..f386accc6 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs @@ -6,12 +6,8 @@ pub enum FundraiserError { TargetNotMet, #[msg("The amount to raise has been achieved")] TargetMet, - #[msg("The contribution is too big")] - ContributionTooBig, #[msg("The contribution is too small")] ContributionTooSmall, - #[msg("The maximum amount to contribute has been reached")] - MaximumContributionsReached, #[msg("The fundraiser has not ended yet")] FundraiserNotEnded, #[msg("The fundraiser has ended")] @@ -22,6 +18,10 @@ pub enum FundraiserError { RefundsOutstanding, #[msg("Arithmetic overflow")] MathOverflow, - #[msg("The fundraiser still exists, so the contributor account closes through refund")] - FundraiserStillOpen, + #[msg("The fundraiser has already been claimed")] + FundraiserClaimed, + #[msg("The fundraiser has not been claimed, so the contributor account closes through refund")] + FundraiserNotClaimed, + #[msg("Contributor accounts for this fundraiser are still open, so it cannot close yet")] + ContributorAccountsOpen, } diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/checker.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/checker.rs index 8eec93b13..3b7c3780f 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/checker.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/checker.rs @@ -1,10 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token_interface::{ - close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, - TransferChecked, - }, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, }; use crate::{state::Fundraiser, FundraiserError}; @@ -20,7 +17,6 @@ pub struct CheckContributionsAccountConstraints<'info> { mut, seeds = [b"fundraiser".as_ref(), maker.key().as_ref()], bump = fundraiser.bump, - close = maker, )] pub fundraiser: Account<'info, Fundraiser>, @@ -48,9 +44,23 @@ pub struct CheckContributionsAccountConstraints<'info> { pub associated_token_program: Program<'info, AssociatedToken>, } +/// Pays the vault out to the maker once the target is met, and marks the +/// fundraiser claimed. +/// +/// The fundraiser account and the vault stay open: contributor accounts are +/// derived from the fundraiser's address, so the fundraiser must outlive every +/// one of them. Otherwise the maker could initialize a new fundraiser at the +/// same address, and contributor accounts left over from this raise would +/// count as contributions to the new one. `close_contributor` closes them, +/// then `close_fundraiser` closes the fundraiser and the vault. pub fn handle_check_contributions( accounts: &mut CheckContributionsAccountConstraints, ) -> Result<()> { + require!( + !accounts.fundraiser.claimed, + FundraiserError::FundraiserClaimed + ); + // Compare the state-tracked total, not the vault balance, so tokens // donated directly to the vault cannot trigger an early release. require!( @@ -58,15 +68,17 @@ pub fn handle_check_contributions( FundraiserError::TargetNotMet ); - // The vault is owned by the fundraiser PDA, so both CPIs are signed with - // its seeds. + accounts.fundraiser.claimed = true; + + // The vault is owned by the fundraiser PDA, so the CPI is signed with its + // seeds. let signer_seeds: [&[&[u8]]; 1] = [&[ b"fundraiser".as_ref(), accounts.maker.to_account_info().key.as_ref(), &[accounts.fundraiser.bump], ]]; - // Drain the whole vault (including any direct donations) to the maker. + // Pay the whole vault (including any direct donations) to the maker. let transfer_accounts = TransferChecked { from: accounts.vault.to_account_info(), mint: accounts.mint_to_raise.to_account_info(), @@ -84,15 +96,5 @@ pub fn handle_check_contributions( accounts.mint_to_raise.decimals, )?; - // Close the empty vault so its rent goes back to the maker. - let close_accounts = CloseAccount { - account: accounts.vault.to_account_info(), - destination: accounts.maker.to_account_info(), - authority: accounts.fundraiser.to_account_info(), - }; - let close_context = - CpiContext::new_with_signer(accounts.token_program.key(), close_accounts, &signer_seeds); - close_account(close_context)?; - Ok(()) } diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close.rs index 5bd758fa1..ab371d28c 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close.rs @@ -49,38 +49,47 @@ pub struct CloseFundraiserAccountConstraints<'info> { pub associated_token_program: Program<'info, AssociatedToken>, } -/// Retires a failed fundraiser so the maker can raise again. +/// Closes a finished fundraiser and its vault so the maker can raise again. /// -/// The fundraiser PDA is derived from the maker's key alone, so while a -/// failed fundraiser's account exists the maker can never initialize -/// another one. This handler closes it once the deadline has passed, the -/// target was missed, and every contribution has been refunded. +/// The fundraiser PDA is derived from the maker's public key alone, so while +/// a fundraiser account exists the maker cannot initialize another one. It +/// closes once no contributor account written for it is still open: after a +/// claim, once `close_contributor` has closed each one; after a failed raise, +/// once the deadline has passed and `refund` has closed each one. pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints) -> Result<()> { - // Closing is allowed only after the fundraiser has ended: - // elapsed_days >= duration. - let current_time = Clock::get()?.unix_timestamp; - let elapsed_days = current_time - .checked_sub(accounts.fundraiser.time_started) - .ok_or(FundraiserError::MathOverflow)? - .checked_div(SECONDS_TO_DAYS) - .ok_or(FundraiserError::MathOverflow)?; - require!( - elapsed_days >= accounts.fundraiser.duration as i64, - FundraiserError::FundraiserNotEnded - ); + if !accounts.fundraiser.claimed { + // Closing an unclaimed fundraiser is allowed only after it has ended: + // elapsed_days >= duration. + let current_time = Clock::get()?.unix_timestamp; + let elapsed_days = current_time + .checked_sub(accounts.fundraiser.time_started) + .ok_or(FundraiserError::MathOverflow)? + .checked_div(SECONDS_TO_DAYS) + .ok_or(FundraiserError::MathOverflow)?; + require!( + elapsed_days >= accounts.fundraiser.duration as i64, + FundraiserError::FundraiserNotEnded + ); - // A successful fundraiser exits through check_contributions, which - // already closes these accounts. - require!( - accounts.fundraiser.current_amount < accounts.fundraiser.amount_to_raise, - FundraiserError::TargetMet - ); + // A raise that met its target closes after the maker claims it. + require!( + accounts.fundraiser.current_amount < accounts.fundraiser.amount_to_raise, + FundraiserError::TargetMet + ); + + // Closing the vault while contributions remain would strand the + // refunds, so every contributor must have been refunded first. + require!( + accounts.fundraiser.current_amount == 0, + FundraiserError::RefundsOutstanding + ); + } - // Closing the vault while contributions remain would strand the - // refunds, so every contributor must have taken theirs first. + // A contributor account left open would be read as a contribution to the + // next fundraiser at this address. require!( - accounts.fundraiser.current_amount == 0, - FundraiserError::RefundsOutstanding + accounts.fundraiser.open_contributor_accounts == 0, + FundraiserError::ContributorAccountsOpen ); // The vault is owned by the fundraiser PDA, so both CPIs are signed with @@ -91,9 +100,9 @@ pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints) &[accounts.fundraiser.bump], ]]; - // Refunds have already drained every tracked contribution, so anything - // left in the vault is a direct donation; sweep it to the maker rather - // than burn it with the account. + // The claim or the refunds have already paid out every tracked + // contribution, so anything left in the vault is a direct donation; pay + // it to the maker rather than burn it with the account. if accounts.vault.amount > 0 { let transfer_accounts = TransferChecked { from: accounts.vault.to_account_info(), diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs index 60920692e..d1d97c749 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs @@ -1,21 +1,23 @@ use anchor_lang::prelude::*; -use crate::{state::Contributor, FundraiserError}; +use crate::{ + state::{Contributor, Fundraiser}, + FundraiserError, +}; #[derive(Accounts)] pub struct CloseContributorAccountConstraints<'info> { + /// Not a signer: the rent goes to the contributor, whoever sends the + /// transaction. So a maker can close every contributor account and then + /// the fundraiser without waiting on any contributor. #[account(mut)] - pub contributor: Signer<'info>, + pub contributor: SystemAccount<'info>, - /// CHECK: the fundraiser this contributor account was written for. The - /// contributor account's seeds bind it to this address, so no other - /// fundraiser can be substituted. The constraint requires the account to - /// be gone: a live fundraiser is owned by this program, and a closed one - /// belongs to the system program again, whatever lamports it holds. #[account( - constraint = *fundraiser.owner != crate::ID @ FundraiserError::FundraiserStillOpen, + mut, + constraint = fundraiser.claimed @ FundraiserError::FundraiserNotClaimed, )] - pub fundraiser: UncheckedAccount<'info>, + pub fundraiser: Account<'info, Fundraiser>, #[account( mut, @@ -26,20 +28,20 @@ pub struct CloseContributorAccountConstraints<'info> { pub contributor_account: Account<'info, Contributor>, } -/// Closes a contributor account once its fundraiser is gone, returning the -/// rent to the contributor. -/// -/// A successful raise exits through `check_contributions`, which closes the -/// vault and the fundraiser but cannot reach the contributor accounts: there -/// is one per contributor and the claim carries none of them. Their other -/// closer, `refund`, runs only on a failed raise. Without this handler every -/// contributor to a successful raise would hold their rent in an account -/// nothing could close. +/// Closes a contributor account once its fundraiser has been claimed, +/// returning the rent to the contributor. /// -/// The one check is that the fundraiser account no longer exists, which is -/// the `constraint` above; the `close = contributor` constraint then returns -/// the rent. While the fundraiser exists the contribution is live, and -/// `refund` is the way to close it. -pub fn handle_close_contributor(_accounts: &mut CloseContributorAccountConstraints) -> Result<()> { +/// `refund` closes contributor accounts on a failed raise. On a successful +/// one the contribution has been paid out to the maker, so the account only +/// holds rent, and `close_fundraiser` cannot run until every one of them is +/// closed. While the fundraiser is unclaimed the contribution can still be +/// refunded, so this handler refuses with `FundraiserNotClaimed`. +pub fn handle_close_contributor(accounts: &mut CloseContributorAccountConstraints) -> Result<()> { + accounts.fundraiser.open_contributor_accounts = accounts + .fundraiser + .open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?; + Ok(()) } diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/contribute.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/contribute.rs index 67e73d2f6..4f2f76aed 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/contribute.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/contribute.rs @@ -5,7 +5,7 @@ use anchor_spl::token_interface::{ use crate::{ state::{Contributor, Fundraiser}, - FundraiserError, MAX_CONTRIBUTION_PERCENTAGE, PERCENTAGE_SCALER, SECONDS_TO_DAYS, + FundraiserError, SECONDS_TO_DAYS, }; #[derive(Accounts)] @@ -53,18 +53,6 @@ pub struct ContributeAccountConstraints<'info> { pub system_program: Program<'info, System>, } -/// Caps a single contributor at MAX_CONTRIBUTION_PERCENTAGE percent of the -/// target. Multiplies in u128 so the product cannot overflow u64. -fn calculate_max_contribution(amount_to_raise: u64) -> Result { - (amount_to_raise as u128) - .checked_mul(MAX_CONTRIBUTION_PERCENTAGE as u128) - .ok_or(FundraiserError::MathOverflow)? - .checked_div(PERCENTAGE_SCALER as u128) - .ok_or(FundraiserError::MathOverflow)? - .try_into() - .map_err(|_| error!(FundraiserError::MathOverflow)) -} - pub fn handle_contribute( accounts: &mut ContributeAccountConstraints, amount: u64, @@ -79,13 +67,13 @@ pub fn handle_contribute( FundraiserError::ContributionTooSmall ); - let max_contribution = calculate_max_contribution(accounts.fundraiser.amount_to_raise)?; + // A claimed fundraiser has paid its vault out to the maker, so a later + // contribution would go to the maker with no refund path. require!( - amount <= max_contribution, - FundraiserError::ContributionTooBig + !accounts.fundraiser.claimed, + FundraiserError::FundraiserClaimed ); - // Contributions are allowed while elapsed_days < duration. let current_time = Clock::get()?.unix_timestamp; let elapsed_days = current_time .checked_sub(accounts.fundraiser.time_started) @@ -97,16 +85,11 @@ pub fn handle_contribute( FundraiserError::FundraiserEnded ); - // The contributor's cumulative total must also stay within the cap. let cumulative_contribution = accounts .contributor_account .amount .checked_add(amount) .ok_or(FundraiserError::MathOverflow)?; - require!( - cumulative_contribution <= max_contribution, - FundraiserError::MaximumContributionsReached - ); // Checks-effects-interactions: update state before the transfer CPI. accounts.fundraiser.current_amount = accounts @@ -116,10 +99,16 @@ pub fn handle_contribute( .ok_or(FundraiserError::MathOverflow)?; accounts.contributor_account.amount = cumulative_contribution; - // Save the contributor PDA bump on first init (init_if_needed only - // runs the init branch once; stored bump is zero until set). + // On first init (init_if_needed only runs the init branch once; the + // stored bump is zero until set), save the contributor PDA bump and count + // the new contributor account against the fundraiser. if accounts.contributor_account.bump == 0 { accounts.contributor_account.bump = bumps.contributor_account; + accounts.fundraiser.open_contributor_accounts = accounts + .fundraiser + .open_contributor_accounts + .checked_add(1) + .ok_or(FundraiserError::MathOverflow)?; } // Transfer the funds from the contributor to the vault. diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/initialize_fundraiser.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/initialize_fundraiser.rs index b105d17a5..e8d651387 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/initialize_fundraiser.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/initialize_fundraiser.rs @@ -64,6 +64,8 @@ pub fn handle_initialize_fundraiser( current_amount: 0, time_started: Clock::get()?.unix_timestamp, duration, + claimed: false, + open_contributor_accounts: 0, bump: bumps.fundraiser, }); diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/refund.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/refund.rs index a58e33dd2..626afe7d2 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/refund.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/refund.rs @@ -10,8 +10,12 @@ use crate::{ #[derive(Accounts)] pub struct RefundAccountConstraints<'info> { + /// Not a signer: the tokens go to the contributor's token account and the + /// rent to the contributor, whoever sends the transaction. So a maker can + /// refund every contributor and close a failed fundraiser without waiting + /// on any of them. #[account(mut)] - pub contributor: Signer<'info>, + pub contributor: SystemAccount<'info>, pub maker: SystemAccount<'info>, @@ -84,6 +88,11 @@ pub fn handle_refund(accounts: &mut RefundAccountConstraints) -> Result<()> { .checked_sub(refund_amount) .ok_or(FundraiserError::MathOverflow)?; accounts.contributor_account.amount = 0; + accounts.fundraiser.open_contributor_accounts = accounts + .fundraiser + .open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?; // Transfer the funds from the vault back to the contributor. The vault is // owned by the fundraiser PDA, so the CPI is signed with its seeds. diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/lib.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/lib.rs index 83e1c2272..0c01c929f 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/lib.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/lib.rs @@ -8,7 +8,7 @@ mod instructions; mod state; pub use constants::*; -use error::*; +pub use error::*; use instructions::*; #[program] diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/state/fundraiser.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/state/fundraiser.rs index ceaf9c2ed..bc5a458bf 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/state/fundraiser.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/state/fundraiser.rs @@ -9,5 +9,13 @@ pub struct Fundraiser { pub current_amount: u64, pub time_started: i64, pub duration: u16, + /// Set by `check_contributions`. A claimed fundraiser accepts no more + /// contributions and no second claim, and stays open until every + /// contributor account written for it has been closed. + pub claimed: bool, + /// How many contributor accounts written for this fundraiser are still + /// open. `close_fundraiser` requires zero, so a new fundraiser at the + /// same address never starts with contributor accounts from an old one. + pub open_contributor_accounts: u32, pub bump: u8, } diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs index 023266289..7022d5106 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs @@ -4,7 +4,7 @@ use { InstructionData, ToAccountMetas, }, borsh::BorshDeserialize, - fundraiser::SECONDS_TO_DAYS, + fundraiser::{FundraiserError, SECONDS_TO_DAYS}, litesvm::LiteSVM, solana_keypair::Keypair, solana_kite::{ @@ -20,10 +20,14 @@ const MINT_DECIMALS: u8 = 6; const ONE_TOKEN: u64 = 1_000_000; /// Comfortably above the program's 3-major-unit minimum target. const AMOUNT_TO_RAISE: u64 = 30 * ONE_TOKEN; -/// The per-contributor cap is 10% of the target. -const MAX_CONTRIBUTION: u64 = AMOUNT_TO_RAISE / 10; +/// Three unequal contributions that together reach the target exactly. +const CONTRIBUTIONS_REACHING_TARGET: [u64; 3] = [12 * ONE_TOKEN, 10 * ONE_TOKEN, 8 * ONE_TOKEN]; +/// A contribution well short of the target on its own. +const CONTRIBUTION: u64 = 4 * ONE_TOKEN; const DURATION_DAYS: u16 = 7; -const CONTRIBUTOR_STARTING_BALANCE: u64 = 10 * ONE_TOKEN; +const CONTRIBUTOR_STARTING_BALANCE: u64 = 20 * ONE_TOKEN; +/// LiteSVM's fee for a transaction with one signature. +const TRANSACTION_FEE: u64 = 5_000; fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -55,6 +59,8 @@ struct FundraiserState { current_amount: u64, _time_started: i64, duration: u16, + claimed: bool, + open_contributor_accounts: u32, _bump: u8, } @@ -286,6 +292,154 @@ fn build_close_fundraiser_instruction(setup: &FundraiserSetup, maker_ata: &Pubke ) } +struct FundedContributor { + keypair: Keypair, + ata: Pubkey, + contributor_account_pda: Pubkey, +} + +/// Sends `contribute` for the given contributor and amount, signed by the +/// contributor. +fn contribute( + setup: &mut FundraiserSetup, + contributor: &FundedContributor, + amount: u64, +) -> Result<(), String> { + let contribute_instruction = build_contribute_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.ata, + &contributor.contributor_account_pda, + amount, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor.keypair], + &contributor.keypair.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +fn new_contributor(setup: &mut FundraiserSetup) -> FundedContributor { + let (keypair, ata, contributor_account_pda) = create_funded_contributor(setup); + FundedContributor { + keypair, + ata, + contributor_account_pda, + } +} + +/// Creates three contributors whose contributions reach the target exactly. +fn fund_to_target(setup: &mut FundraiserSetup) -> Vec { + CONTRIBUTIONS_REACHING_TARGET + .iter() + .map(|amount| { + let contributor = new_contributor(setup); + contribute(setup, &contributor, *amount).unwrap(); + contributor + }) + .collect() +} + +/// Sends `refund` for `contributor`, paid for by `fee_payer`, who need not be the contributor. +fn refund( + setup: &mut FundraiserSetup, + fee_payer: &Keypair, + contributor: &FundedContributor, +) -> Result<(), String> { + let refund_instruction = build_refund_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.ata, + &contributor.contributor_account_pda, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![refund_instruction], + &[fee_payer], + &fee_payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `check_contributions`, signed by the maker. +fn claim(setup: &mut FundraiserSetup) -> Result { + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let check_instruction = build_check_contributions_instruction(setup, &maker_ata); + let maker = setup.maker.insecure_clone(); + send_transaction_from_instructions( + &mut setup.svm, + vec![check_instruction], + &[&maker], + &maker.pubkey(), + ) + .map(|_| maker_ata) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `close_contributor` for `contributor`, signed and paid for by +/// `fee_payer`. +fn close_contributor( + setup: &mut FundraiserSetup, + fee_payer: &Keypair, + contributor: &FundedContributor, +) -> Result<(), String> { + let close_instruction = build_close_contributor_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.contributor_account_pda, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[fee_payer], + &fee_payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `close_fundraiser`, signed by the maker. +fn close_fundraiser(setup: &mut FundraiserSetup) -> Result<(), String> { + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let close_instruction = build_close_fundraiser_instruction(setup, &maker_ata); + let maker = setup.maker.insecure_clone(); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[&maker], + &maker.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +fn lamports(setup: &FundraiserSetup, address: &Pubkey) -> u64 { + setup + .svm + .get_account(address) + .map_or(0, |account| account.lamports) +} + +/// Anchor numbers a program's `#[error_code]` variants from 6000. +const ANCHOR_ERROR_CODE_OFFSET: u32 = 6000; + +/// Asserts that a transaction failed with the given program error. +fn assert_error(result: Result, expected_error: FundraiserError) { + let error = result.expect_err("transaction should have failed"); + let expected_code = format!( + "Custom({})", + ANCHOR_ERROR_CODE_OFFSET + expected_error as u32 + ); + assert!( + error.contains(&expected_code), + "expected {expected_code}, got: {error}" + ); +} + #[test] fn test_initialize_fundraiser() { let mut setup = full_setup(); @@ -296,6 +450,8 @@ fn test_initialize_fundraiser() { assert_eq!(fundraiser_state.amount_to_raise, AMOUNT_TO_RAISE); assert_eq!(fundraiser_state.current_amount, 0); assert_eq!(fundraiser_state.duration, DURATION_DAYS); + assert!(!fundraiser_state.claimed); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -332,11 +488,9 @@ fn test_initialize_below_minimum_target_fails() { vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Target below 3 major units must be rejected" - ); + ) + .map_err(|error| format!("{error:?}")); + assert_error(result, FundraiserError::InvalidAmount); assert!( setup.svm.get_account(&setup.fundraiser_pda).is_none(), "Fundraiser account must not exist after a failed initialize" @@ -347,76 +501,78 @@ fn test_initialize_below_minimum_target_fails() { fn test_contribute_inside_window_succeeds() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); + let contributor = new_contributor(&mut setup); // One day in: well inside the 7-day window. warp_days_forward(&mut setup.svm, 1); - - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - MAX_CONTRIBUTION + CONTRIBUTION ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), - CONTRIBUTOR_STARTING_BALANCE - MAX_CONTRIBUTION + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE - CONTRIBUTION ); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); - assert_eq!(fundraiser_state.current_amount, MAX_CONTRIBUTION); + assert_eq!(fundraiser_state.current_amount, CONTRIBUTION); + assert_eq!(fundraiser_state.open_contributor_accounts, 1); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!(contributor_state.amount, CONTRIBUTION); } #[test] -fn test_contribute_after_deadline_fails() { +fn test_contributions_accumulate_in_one_contributor_account() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // One day past the deadline. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + let first_contribution = 5 * ONE_TOKEN; + let second_contribution = 3 * ONE_TOKEN; + contribute(&mut setup, &contributor, first_contribution).unwrap(); + warp_days_forward(&mut setup.svm, 1); + contribute(&mut setup, &contributor, second_contribution).unwrap(); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN, + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!( + contributor_state.amount, + first_contribution + second_contribution ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), + + // A second contribution adds to the existing account rather than + // creating another, so the fundraiser still counts one. + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!( + fundraiser_state.current_amount, + first_contribution + second_contribution ); - assert!(result.is_err(), "Contributing after the deadline must fail"); + assert_eq!(fundraiser_state.open_contributor_accounts, 1); +} + +#[test] +fn test_contribute_after_deadline_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + + // The deadline falls exactly DURATION_DAYS after the start. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + assert_error( + contribute(&mut setup, &contributor, ONE_TOKEN), + FundraiserError::FundraiserEnded, + ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0 ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), CONTRIBUTOR_STARTING_BALANCE ); } @@ -425,26 +581,11 @@ fn test_contribute_after_deadline_fails() { fn test_contribute_below_one_major_unit_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN - 1, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "Contributions below one major unit must fail" + assert_error( + contribute(&mut setup, &contributor, ONE_TOKEN - 1), + FundraiserError::ContributionTooSmall, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -456,40 +597,17 @@ fn test_contribute_below_one_major_unit_fails() { fn test_refund_before_deadline_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, ONE_TOKEN).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); + // One day short of the deadline. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 - 1); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - - // Still inside the window: refund must fail with FundraiserNotEnded. - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), + let fee_payer = contributor.keypair.insecure_clone(); + assert_error( + refund(&mut setup, &fee_payer, &contributor), + FundraiserError::FundraiserNotEnded, ); - assert!(result.is_err(), "Refunding before the deadline must fail"); - assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), ONE_TOKEN @@ -502,105 +620,74 @@ fn test_refund_before_deadline_fails() { fn test_refund_after_deadline_target_not_met_succeeds() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); // Past the deadline, target not met: refund must succeed. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, &contributor).unwrap(); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0 ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), CONTRIBUTOR_STARTING_BALANCE ); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); assert_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); assert!( - setup.svm.get_account(&contributor_account_pda).is_none(), + setup + .svm + .get_account(&contributor.contributor_account_pda) + .is_none(), "Contributor account must be closed after refund" ); } #[test] -fn test_refund_when_target_met_fails() { +fn test_anyone_can_refund_a_contributor() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - // 10 contributors at the 10% cap reach the target exactly. - let mut contributors = Vec::new(); - for _ in 0..10 { - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - contributors.push((contributor, contributor_ata, contributor_account_pda)); - } - - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + // The maker sends the refund. The tokens and the rent still go to the + // contributor, who signs nothing. + let rent = lamports(&setup, &contributor.contributor_account_pda); + let contributor_lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + let maker = setup.maker.insecure_clone(); + refund(&mut setup, &maker, &contributor).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = &contributors[0]; - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - contributor_ata, - contributor_account_pda, + assert_eq!( + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[contributor], - &contributor.pubkey(), + assert_eq!( + lamports(&setup, &contributor.keypair.pubkey()), + contributor_lamports_before + rent ); - assert!( - result.is_err(), - "Refunding must fail once the target has been met" +} + +#[test] +fn test_refund_when_target_met_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + + let fee_payer = contributors[0].keypair.insecure_clone(); + assert_error( + refund(&mut setup, &fee_payer, &contributors[0]), + FundraiserError::TargetMet, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -609,195 +696,189 @@ fn test_refund_when_target_met_fails() { } #[test] -fn test_check_contributions_success_pays_maker_and_closes_vault() { +fn test_check_contributions_success_pays_maker_and_marks_claimed() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); - // 10 contributors at the 10% cap reach the target exactly. - for _ in 0..10 { - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - } + let maker_ata = claim(&mut setup).unwrap(); assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), AMOUNT_TO_RAISE ); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + 0 + ); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); - send_transaction_from_instructions( + // The fundraiser stays open, marked claimed, until every contributor + // account written for it is closed. + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(fundraiser_state.claimed); + assert_eq!( + fundraiser_state.open_contributor_accounts, + CONTRIBUTIONS_REACHING_TARGET.len() as u32 + ); +} + +#[test] +fn test_second_claim_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + let maker_ata = claim(&mut setup).unwrap(); + + // A donation to the vault after the claim must not make a second claim + // possible. + mint_tokens_to_token_account( &mut setup.svm, - vec![check_instruction], - &[&setup.maker], - &setup.maker.pubkey(), + &setup.mint, + &setup.vault, + ONE_TOKEN, + &setup.payer, ) .unwrap(); + setup.svm.expire_blockhash(); + assert_error(claim(&mut setup), FundraiserError::FundraiserClaimed); assert_eq!( get_token_account_balance(&setup.svm, &maker_ata).unwrap(), AMOUNT_TO_RAISE ); - assert!( - setup.svm.get_account(&setup.vault).is_none(), - "Vault token account must be closed after a successful claim" - ); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_none(), - "Fundraiser account must be closed after a successful claim" - ); } #[test] -fn test_contribute_above_cap_fails() { +fn test_contribute_after_claim_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // One minor unit over the 10% cap must fail with ContributionTooBig. - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION + 1, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "A single contribution above the 10% cap must fail" + // The deadline is still days away, but the vault has been paid out. + warp_days_forward(&mut setup.svm, 1); + let late_contributor = new_contributor(&mut setup); + assert_error( + contribute(&mut setup, &late_contributor, CONTRIBUTION), + FundraiserError::FundraiserClaimed, ); assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - 0 + get_token_account_balance(&setup.svm, &late_contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE ); } #[test] -fn test_cumulative_contributions_above_cap_fail() { +fn test_check_contributions_ignores_direct_vault_donations() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // Each call is under the cap on its own; the second pushes the - // cumulative total over it and must fail with - // MaximumContributionsReached. - let first_contribution = 2 * ONE_TOKEN; - let second_contribution = MAX_CONTRIBUTION - ONE_TOKEN; - - let first_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - first_contribution, - ); - send_transaction_from_instructions( + // Mint the full target straight into the vault, bypassing contribute. + // The state-tracked current_amount stays 0, so the claim must fail. + mint_tokens_to_token_account( &mut setup.svm, - vec![first_instruction], - &[&contributor], - &contributor.pubkey(), + &setup.mint, + &setup.vault, + AMOUNT_TO_RAISE, + &setup.payer, ) .unwrap(); - let second_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - second_contribution, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![second_instruction], - &[&contributor], - &contributor.pubkey(), - ); + assert_error(claim(&mut setup), FundraiserError::TargetNotMet); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(!fundraiser_state.claimed); +} + +#[test] +fn test_close_contributor_after_claim_returns_rent() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + let contributor = &contributors[0]; + let rent = lamports(&setup, &contributor.contributor_account_pda); + let lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + + let fee_payer = contributor.keypair.insecure_clone(); + close_contributor(&mut setup, &fee_payer, contributor).unwrap(); + assert!( - result.is_err(), - "Contributions that cumulatively exceed the 10% cap must fail" + setup + .svm + .get_account(&contributor.contributor_account_pda) + .is_none(), + "Contributor account must be closed" ); - + // The contributor paid the transaction fee out of the same balance, so + // the rent came back less that fee. assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - first_contribution + lamports(&setup, &contributor.keypair.pubkey()), + lamports_before + rent - TRANSACTION_FEE + ); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!( + fundraiser_state.open_contributor_accounts, + CONTRIBUTIONS_REACHING_TARGET.len() as u32 - 1 ); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, first_contribution); } #[test] -fn test_close_fundraiser_after_failed_raise_allows_a_new_raise() { +fn test_anyone_can_close_contributor_accounts_after_claim() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + // The maker closes every contributor account; each rent deposit goes to + // its contributor, who signs nothing. + let maker = setup.maker.insecure_clone(); + for contributor in &contributors { + let rent = lamports(&setup, &contributor.contributor_account_pda); + let lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + close_contributor(&mut setup, &maker, contributor).unwrap(); + assert_eq!( + lamports(&setup, &contributor.keypair.pubkey()), + lamports_before + rent + ); + } - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); +} - // The raise fails; the contributor takes their refund. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_contributor_before_claim_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + + // The fundraiser is unclaimed, so the contribution can still be + // refunded: closing the account now would erase what the vault owes. + let fee_payer = contributor.keypair.insecure_clone(); + assert_error( + close_contributor(&mut setup, &fee_payer, &contributor), + FundraiserError::FundraiserNotClaimed, + ); + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!(contributor_state.amount, CONTRIBUTION); +} - // The maker retires the failed fundraiser. - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_fundraiser_after_failed_raise_allows_a_new_raise() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + // The raise fails; the contributor takes their refund. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, &contributor).unwrap(); + + close_fundraiser(&mut setup).unwrap(); assert!( setup.svm.get_account(&setup.vault).is_none(), "Vault token account must be closed with the fundraiser" @@ -827,17 +908,12 @@ fn test_close_fundraiser_before_deadline_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing a fundraiser before its deadline must fail" + // One day short of the deadline. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 - 1); + + assert_error( + close_fundraiser(&mut setup), + FundraiserError::FundraiserNotEnded, ); assert!( setup.svm.get_account(&setup.fundraiser_pda).is_some(), @@ -849,86 +925,33 @@ fn test_close_fundraiser_before_deadline_fails() { fn test_close_fundraiser_with_unrefunded_contributions_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); // Past the deadline but the contribution has not been refunded, so // closing would strand it in the vault. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing must fail while contributions remain unrefunded" + assert_error( + close_fundraiser(&mut setup), + FundraiserError::RefundsOutstanding, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - MAX_CONTRIBUTION + CONTRIBUTION ); } #[test] -fn test_close_fundraiser_when_target_met_fails() { +fn test_close_fundraiser_when_target_met_but_unclaimed_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); - // 10 contributors at the 10% cap reach the target exactly. - for _ in 0..10 { - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - } + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - - // A successful raise exits through check_contributions, never close. - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing must fail when the target was met; the claim is the exit" - ); + // A raise that met its target closes only after the maker claims it. + assert_error(close_fundraiser(&mut setup), FundraiserError::TargetMet); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), AMOUNT_TO_RAISE @@ -936,200 +959,169 @@ fn test_close_fundraiser_when_target_met_fails() { } #[test] -fn test_close_fundraiser_sweeps_direct_donations_to_maker() { +fn test_close_fundraiser_with_open_contributor_accounts_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - // Tokens sent straight to the vault are outside the program's - // accounting; on close they go to the maker instead of being burned - // with the account. - let donation = 5 * ONE_TOKEN; - mint_tokens_to_token_account( - &mut setup.svm, - &setup.mint, - &setup.vault, - donation, - &setup.payer, - ) - .unwrap(); - - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); + // Close all but one contributor account. + let maker = setup.maker.insecure_clone(); + for contributor in &contributors[1..] { + close_contributor(&mut setup, &maker, contributor).unwrap(); + } - assert_eq!( - get_token_account_balance(&setup.svm, &maker_ata).unwrap(), - donation + assert_error( + close_fundraiser(&mut setup), + FundraiserError::ContributorAccountsOpen, ); - assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); - assert!(setup.svm.get_account(&setup.vault).is_none()); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_some()); } #[test] -fn test_check_contributions_ignores_direct_vault_donations() { +fn test_reinitialize_with_open_contributor_accounts_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - // Mint the full target straight into the vault, bypassing contribute. - // The state-tracked current_amount stays 0, so the claim must fail. - mint_tokens_to_token_account( - &mut setup.svm, - &setup.mint, - &setup.vault, - AMOUNT_TO_RAISE, - &setup.payer, - ) - .unwrap(); - - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); + // The claimed fundraiser account still exists, so a new fundraiser + // cannot be initialized at its address. + setup.svm.expire_blockhash(); + let initialize_instruction = Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::InitializeFundraiser { + amount: AMOUNT_TO_RAISE, + duration: DURATION_DAYS, + } + .data(), + fundraiser::accounts::InitializeFundraiserAccountConstraints { + maker: setup.maker.pubkey(), + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + vault: setup.vault, + system_program: system_program::id(), + token_program: token_program_id(), + associated_token_program: ata_program_id(), + } + .to_account_metas(None), + ); let result = send_transaction_from_instructions( &mut setup.svm, - vec![check_instruction], + vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), ); assert!( result.is_err(), - "Direct donations to the vault must not unlock the claim" - ); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_some(), - "Fundraiser account must stay open after a failed claim" + "A new fundraiser must not start while the claimed one exists" ); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(fundraiser_state.claimed); } #[test] -fn test_close_contributor_after_successful_claim_returns_rent() { +fn test_stale_contributor_account_cannot_refund_from_next_raise() { let mut setup = full_setup(); - initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - // 10 contributors at the 10% cap reach the target exactly. - let mut contributors = Vec::new(); - for _ in 0..10 { - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - contributors.push((contributor, contributor_account_pda)); + // Raise one succeeds and the maker claims it. + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let first_raise_contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + // The maker closes every contributor account from raise one, then the + // fundraiser, and starts raise two at the same address. + let maker = setup.maker.insecure_clone(); + for contributor in &first_raise_contributors { + close_contributor(&mut setup, &maker, contributor).unwrap(); } + close_fundraiser(&mut setup).unwrap(); + setup.svm.expire_blockhash(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![check_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_none(), - "Fundraiser account must be closed after a successful claim" + // Raise two collects less than the target and fails. + let second_raise_contributors: Vec = (0..2) + .map(|_| { + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + contributor + }) + .collect(); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + + // A raise-one contributor tries to take a refund from raise two. Their + // contributor account was closed with raise one, so there is nothing to + // refund. + let stale_contributor = &first_raise_contributors[0]; + let fee_payer = stale_contributor.keypair.insecure_clone(); + assert!(refund(&mut setup, &fee_payer, stale_contributor).is_err()); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + 2 * CONTRIBUTION ); - // The claim closed the fundraiser and the vault, but every contributor - // account is still open with its rent inside. - let (contributor, contributor_account_pda) = &contributors[0]; - let rent = setup - .svm - .get_account(contributor_account_pda) - .expect("Contributor account survives the claim") - .lamports; - let lamports_before = setup - .svm - .get_account(&contributor.pubkey()) - .unwrap() - .lamports; + // Every raise-two contributor is refunded in full. + for contributor in &second_raise_contributors { + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, contributor).unwrap(); + assert_eq!( + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE + ); + } + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); +} - let close_instruction = - build_close_contributor_instruction(&setup, &contributor.pubkey(), contributor_account_pda); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[contributor], - &contributor.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_fundraiser_after_claim_allows_a_new_raise() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - assert!( - setup.svm.get_account(contributor_account_pda).is_none(), - "Contributor account must be closed" - ); - let lamports_after = setup - .svm - .get_account(&contributor.pubkey()) - .unwrap() - .lamports; - // The contributor paid the transaction fee out of the same balance, so - // the rent came back less that fee. - let fee = 5_000; - assert_eq!( - lamports_after, - lamports_before + rent - fee, - "The contributor account's rent must return to the contributor" - ); + let maker = setup.maker.insecure_clone(); + for contributor in &contributors { + close_contributor(&mut setup, &maker, contributor).unwrap(); + } + close_fundraiser(&mut setup).unwrap(); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); + assert!(setup.svm.get_account(&setup.vault).is_none()); + + setup.svm.expire_blockhash(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(!fundraiser_state.claimed); + assert_eq!(fundraiser_state.current_amount, 0); } #[test] -fn test_close_contributor_while_fundraiser_open_fails() { +fn test_close_fundraiser_sweeps_direct_donations_to_maker() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION, - ); - send_transaction_from_instructions( + // Tokens sent straight to the vault are outside the program's + // accounting; on close they go to the maker instead of being burned + // with the account. + let donation = 5 * ONE_TOKEN; + mint_tokens_to_token_account( &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), + &setup.mint, + &setup.vault, + donation, + &setup.payer, ) .unwrap(); - // The fundraiser is live, so the contribution is live too: closing the - // record now would erase what the vault owes this contributor. - let close_instruction = build_close_contributor_instruction( - &setup, - &contributor.pubkey(), - &contributor_account_pda, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "Closing a contributor account must fail while its fundraiser exists" + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + close_fundraiser(&mut setup).unwrap(); + + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + assert_eq!( + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), + donation ); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); + assert!(setup.svm.get_account(&setup.vault).is_none()); } From 1f1a46e0dfd5a059d7410644f70f6c92b6f9373d Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 20:10:26 +0000 Subject: [PATCH 4/5] perpetual-futures: initial margin, and a pool-maintained price average with a trading band Initial margin: max_leverage becomes initial_margin_bps, which must exceed maintenance_margin_bps (InitialMarginNotAboveMaintenance). open_position requires net collateral of at least initial_margin_bps of the size (InitialMarginNotMet); the separate maintenance check at open is implied. Price band: the pool keeps a time-weighted average of the oracle price (average_price, last_oracle_price, average_price_timestamp, ten-minute window). Each fold credits the elapsed time to the price seen at the previous read, so one manipulated read after an idle spell moves nothing. open_position, close_position, add_liquidity and remove_liquidity refuse an oracle price more than max_price_deviation_bps from the average (PriceOutsideBand). liquidate_position and the new permissionless update_price_average() fold without the check, so liquidations run through a genuine move and the average can catch up with it. All three implementations; anchor 37, anchor-v1 37, quasar 28 tests. Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- .../perpetual-futures/anchor-v1/CHANGELOG.md | 58 ++ finance/perpetual-futures/anchor-v1/README.md | 42 +- .../anchor-v1/TERMINOLOGY.md | 43 +- .../perpetual-futures/src/constants.rs | 16 +- .../programs/perpetual-futures/src/errors.rs | 13 +- .../src/instructions/add_liquidity.rs | 4 +- .../src/instructions/close_position.rs | 6 +- .../src/instructions/initialize_pool.rs | 61 +- .../perpetual-futures/src/instructions/mod.rs | 2 + .../src/instructions/open_position.rs | 31 +- .../src/instructions/remove_liquidity.rs | 4 +- .../src/instructions/shared.rs | 110 +++- .../src/instructions/update_price_average.rs | 30 + .../programs/perpetual-futures/src/lib.rs | 17 +- .../perpetual-futures/src/state/pool.rs | 31 +- .../tests/test_perpetual_futures.rs | 519 +++++++++++++++--- finance/perpetual-futures/anchor/CHANGELOG.md | 58 ++ finance/perpetual-futures/anchor/README.md | 42 +- .../perpetual-futures/anchor/TERMINOLOGY.md | 43 +- .../perpetual-futures/src/constants.rs | 16 +- .../programs/perpetual-futures/src/errors.rs | 13 +- .../src/instructions/add_liquidity.rs | 4 +- .../src/instructions/close_position.rs | 6 +- .../src/instructions/initialize_pool.rs | 61 +- .../perpetual-futures/src/instructions/mod.rs | 2 + .../src/instructions/open_position.rs | 31 +- .../src/instructions/remove_liquidity.rs | 4 +- .../src/instructions/shared.rs | 110 +++- .../src/instructions/update_price_average.rs | 30 + .../programs/perpetual-futures/src/lib.rs | 17 +- .../perpetual-futures/src/state/pool.rs | 31 +- .../tests/test_perpetual_futures.rs | 519 +++++++++++++++--- finance/perpetual-futures/quasar/CHANGELOG.md | 60 ++ finance/perpetual-futures/quasar/README.md | 30 +- .../perpetual-futures/quasar/src/constants.rs | 13 +- .../quasar/src/instructions/add_liquidity.rs | 6 +- .../quasar/src/instructions/close_position.rs | 5 +- .../src/instructions/initialize_pool.rs | 43 +- .../quasar/src/instructions/mod.rs | 2 + .../quasar/src/instructions/open_position.rs | 31 +- .../src/instructions/remove_liquidity.rs | 6 +- .../quasar/src/instructions/shared.rs | 149 ++++- .../src/instructions/update_price_average.rs | 34 ++ finance/perpetual-futures/quasar/src/lib.rs | 17 +- finance/perpetual-futures/quasar/src/state.rs | 22 +- finance/perpetual-futures/quasar/src/tests.rs | 391 +++++++++++-- 46 files changed, 2417 insertions(+), 366 deletions(-) create mode 100644 finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs create mode 100644 finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs create mode 100644 finance/perpetual-futures/quasar/src/instructions/update_price_average.rs diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index f2ee8472b..6e52940fb 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +## 2026-10-01 + +Replace the leverage cap with an initial margin. `max_leverage` on +`PoolParameters` and `Pool` is now `initial_margin_bps`, the net collateral a +position must post to open, in basis points of its size (1,000 is 10x). +`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= +10_000`, refusing an initial margin at or below the maintenance margin with the +new `InitialMarginNotAboveMaintenance` and one above 10,000 with +`InvalidParameter`; `MAX_LEVERAGE_CEILING` is removed. `open_position` checks +`net_collateral * 10_000 >= size * initial_margin_bps` and fails with +`InitialMarginNotMet`, which takes `LeverageTooHigh`'s place and its error code +(6004). Its separate check that a new position starts above the maintenance +margin is removed, because the initial margin implies it; `PositionNotHealthy` +remains for `close_position`. + +Add a price band around a program-maintained average price. A fresh, confident +oracle print could still be wrong, and every handler traded at it. The pool now +keeps `average_price`, a time-weighted moving average of the oracle price, +`last_oracle_price`, the price at the most recent oracle read, and +`average_price_timestamp`. `initialize_pool` seeds the average and +`last_oracle_price` from the oracle. Every handler that reads the oracle credits +the seconds since the previous read to the price that read saw, +`average += (last_oracle_price - average) * min(elapsed, +PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new +constant at 600 seconds, and then records the price it read as +`last_oracle_price`. The price read now only counts from now, so a pool left +idle for a window or more cannot have its average set by one read of a +manipulated price: that price moves the average only if the oracle still shows +it at a later read, weighted by the seconds between the two reads. +`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse +a price outside `|price - average_price| * 10_000 <= average_price * +max_price_deviation_bps` with the new `PriceOutsideBand`, checked against the +stored average before anything is folded in. `liquidate_position` folds and +records without the check. The new permissionless `update_price_average` +handler folds and records too, also without the check, so keepers calling it +repeatedly as time passes can walk the average to a genuine move. `max_price_deviation_bps` is a new +`PoolParameters` field, which `initialize_pool` requires to be above zero and +below 10,000 with the new `InvalidPriceDeviation`. `shared.rs` has +`refresh_price_and_funding_within_band` for the four band-checked handlers +beside `refresh_price_and_funding` for the other two. The `errors` module is +public so the tests can match `PerpError` codes. + +Tested by `test_open_rejects_position_below_initial_margin` (formerly +`test_open_rejects_excess_leverage`, now checking both sides of the boundary), +`test_initialize_pool_rejects_initial_margin_at_or_below_maintenance`, +`test_initialize_pool_rejects_price_deviation_outside_range`, +`test_open_rejected_when_oracle_jumps_outside_band`, +`test_close_rejected_when_oracle_jumps_outside_band`, +`test_liquidity_changes_rejected_when_oracle_jumps_outside_band`, +`test_liquidation_runs_outside_band`, +`test_price_average_catches_up_after_genuine_move`, +`test_single_update_moves_average_by_elapsed_fraction` and +`test_one_manipulated_read_after_idle_does_not_move_average`. The default test market +uses a 1,000 basis point initial margin and a 2,000 basis point band; +`test_profit_capped_at_reserved_notional` triples the price, far outside the +band, so it now calls `update_price_average` to record the new price, lets a +full window pass, and calls it again before closing. + ## 2026-09-30 Remove `set_funding_rate`. The pool's authority could change the funding rate at diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index d2e38e217..6eecb2174 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -29,7 +29,7 @@ All arithmetic is integer `u128` with `checked_*` operations, multiplying before ### Long and short, leverage, collateral -A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size up to the pool's maximum [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power). The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: +A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size, and the pool's [initial margin](https://www.investopedia.com/terms/i/initialmargin.asp) caps their [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power): `open_position` requires the collateral left after the open fee to be at least `initial_margin_bps` of the size, checked as `net_collateral * 10_000 >= size * initial_margin_bps`, and fails with `InitialMarginNotMet` otherwise. An initial margin of 1,000 basis points (10%) allows at most 10× leverage. The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: ``` long profit/loss = size * (price - entry_price) / entry_price @@ -54,10 +54,25 @@ Funding runs on the wall clock rather than the slot count, so what a position co A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless: anyone can crank it and earn the liquidation fee. +`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= 10_000` and refuses anything else with `InitialMarginNotAboveMaintenance` (or `InvalidParameter` above 10,000). Every position therefore opens with more margin than it is liquidated at, so none can be liquidated in the slot it opened. + ### Oracle The mark price comes from an oracle feed. This example validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. +### Price band + +A single oracle print can be wrong while still being fresh, positive and confident: a publisher fault, or a thin market moved for a few seconds. To stop anyone trading against such a print, the pool keeps its own time-weighted moving average of the oracle price, `Pool.average_price`, and refuses prices too far from it. + +- `initialize_pool` reads the oracle and seeds both `average_price` and `last_oracle_price` with its price, stamping `average_price_timestamp` with the Clock's `unix_timestamp`. +- Every handler that reads the oracle credits the seconds since the previous read to the price that read saw, `last_oracle_price`, on the assumption that it held throughout: `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`. It then records the price it read as the new `last_oracle_price`. The window is 600 seconds, so a price seen at two reads six seconds apart moves the average by 1% of its gap from the average, and an interval of ten minutes or more replaces the average with the price seen at its start. +- The price read now only starts counting from now. A manipulated price moves the average only if the oracle still shows it at a later read, and only by the seconds between the two reads; a read of the real price in between replaces it. A pool left idle for longer than the window therefore cannot have its average set by a single read. +- `open_position`, `close_position`, `add_liquidity` and `remove_liquidity` first check the price against the stored average, before anything is folded in: `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. A price outside that band fails with `PriceOutsideBand`, and the pool is left unchanged. +- `liquidate_position` folds and records without the band check. A genuine crash is when positions go underwater, so liquidation keeps working through one. +- `update_price_average()` is permissionless: any signer passes the pool and its oracle feed, and the handler reads and validates the oracle with the same checks, accrues funding, folds the elapsed interval in and records the price, with no band check. After a genuine move takes the oracle outside the band, keepers call it repeatedly as time passes: the first call records the new price, and each later call credits the time since the previous one to it, until the average is close enough to the price for trading to resume. + +`max_price_deviation_bps` is fixed by `initialize_pool`, which refuses zero (every move would be refused) and 10,000 or more (a fall could never be refused, since prices are positive) with `InvalidPriceDeviation`. + ### Fees and slippage Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional and accrue to the program. Every state-changing handler takes a `minimum_*` / acceptable-price bound (protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill) and reverts if the bound is breached. Pass `0` to opt out. @@ -74,7 +89,7 @@ Open and close fees are charged in [basis points](https://www.investopedia.com/t - **Bob** (Short trader): He thinks NVDA will fall and wants to profit from the downside. - **Dave** (Liquidator): Runs a bot that closes under-margined positions to earn the liquidation fee. -Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). The pool is configured with 10× max leverage, 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, and a 1% maximum oracle confidence band. +Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). The pool is configured with a 10% initial margin (10× leverage), 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, a 1% maximum oracle confidence band, and a 20% price band around its average price. --- @@ -82,9 +97,11 @@ Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). T **Instruction:** `initialize_pool(parameters)` +The handler validates the parameters, then reads the oracle once to seed the pool's average price at $100. + **Accounts created:** -- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA +- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, average oracle price, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA - `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA, seeds `["vault", pool]`: all USDC, both provider liquidity and trader collateral; `pool` is its owner - `lp_mint` PDA, seeds `["lp_mint", pool]`: the share [mint](https://solana.com/docs/terminology#mint-account); `pool` is the mint authority @@ -137,7 +154,7 @@ While both are open, **funding** accrues to the pool from the heavier side; it i **Instruction:** `close_position(minimum_payout)` -Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reserve cap), minus the $5 close fee. +$116 is 16% above the pool's $100 average price, inside the 20% band, so the close goes through. Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reserve cap), minus the $5 close fee. **Accounts modified:** @@ -145,6 +162,8 @@ Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reser - `Pool.reserved_liquidity`: −$5,000 (reserve released) - `Pool.total_collateral`: −$995 - `Pool.program_fees`: +$5 +- `Pool.average_price`: credits the time since the last read to $100, the price that read saw, so it stays at $100 +- `Pool.last_oracle_price`: $100 → $116, which the next read credits for the time in between - long open-interest accumulators: −= this position - `custody_vault` → `alice_usdc`: pays out $1,790 (net collateral + profit − close fee) - `Position` (Alice): closed; rent returned to Alice @@ -195,7 +214,7 @@ The genuinely hard part of a perpetual-futures venue is keeping it solvent and p - **Account-local safety**: "every favorable action refreshes the account's full active portfolio first; … stale … legs fail closed." Here, every position and liquidity action reads a fresh oracle (stale or wide-confidence prices are rejected) and recomputes pool exposure before any payout. - **Bounded progress**: "no public instruction needs to evaluate the whole market." Here, assets-under-management comes from running per-side accumulators, and liquidation acts on one position at a time, so no handler's cost grows with the number of open positions. -What production pool-perps (`solana-labs/perpetuals`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, and using the oracle's EMA for a less manipulable mark. +What production pool-perps (`solana-labs/perpetuals`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, and valuing positions at the oracle's EMA rather than its spot price. This example keeps its own average only to decide when to refuse trading, and values positions at the spot price. --- @@ -212,7 +231,18 @@ This is a teaching example, not an audited exchange. Notably: ## Testing -The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price, pre-restart-price, and wide-confidence rejection, funding accrual, funding-rate retuning (including that it settles elapsed seconds at the old rate, and that only the authority may call it), funding that follows seconds rather than slots, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. +The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover: + +- liquidity round-trips, and share inflation through a provider's own trades +- opening and closing longs and shorts in profit and loss +- the initial margin on both sides of its boundary, and slippage rejection +- stale-price, pre-restart-price, and wide-confidence rejection +- funding accrual, the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots +- the price band: opens, closes, deposits and withdrawals refused when the oracle jumps outside it (`test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band`, `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`), liquidation running outside it (`test_liquidation_runs_outside_band`), the exact average after each `update_price_average` (`test_single_update_moves_average_by_elapsed_fraction`), repeated updates walking the average to a genuine move until trading resumes (`test_price_average_catches_up_after_genuine_move`), and one manipulated read after an idle window leaving the average where it was (`test_one_manipulated_read_after_idle_does_not_move_average`) +- liquidation, and the refusal to liquidate a healthy position +- reserved-liquidity behaviour: profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity +- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin and a price band outside its range +- fee collection ```bash anchor build diff --git a/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md b/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md index 26c7bd5e0..55493ce89 100644 --- a/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md +++ b/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md @@ -2,36 +2,47 @@ Terms used in this example, in the sense they carry here. -- **Perpetual future (perp)** — a leveraged derivative position with no expiry +- **Perpetual future (perp)**: a leveraged derivative position with no expiry and no settlement date. Profit and loss is paid in the collateral token as the oracle price moves. -- **Long / short** — a long profits when the price rises, a short when it falls. +- **Long / short**: a long profits when the price rises, a short when it falls. Each is the opposite side of the pool's exposure. -- **Collateral** — the token a trader posts to back a position, and the token +- **Collateral**: the token a trader posts to back a position, and the token liquidity providers deposit. One pool uses one collateral token. -- **Notional size** — the position's exposure in collateral units. Profit and +- **Notional size**: the position's exposure in collateral units. Profit and loss scales with the notional, not with the collateral posted. -- **Leverage** — notional size divided by collateral. A pool caps it at - `max_leverage`. -- **Equity** — a position's current worth: net collateral plus unrealized profit +- **Leverage**: notional size divided by collateral. A pool caps it through its + initial margin: 1,000 basis points (10%) allows at most 10x. +- **Initial margin**: the net collateral, as a fraction of notional size, a + position must post to open (`initial_margin_bps`). Always above the + maintenance margin, so no position opens already liquidatable. +- **Equity**: a position's current worth: net collateral plus unrealized profit and loss, minus accrued funding. When equity falls to the maintenance margin, the position is liquidatable. -- **Maintenance margin** — the minimum equity, as a fraction of notional size, +- **Maintenance margin**: the minimum equity, as a fraction of notional size, a position must keep to avoid liquidation. -- **Liquidation** — closing an under-margined position. Permissionless here: any +- **Liquidation**: closing an under-margined position. Permissionless here: any caller can trigger it and earns the liquidation fee. -- **Funding** — a periodic payment that anchors the pool's risk. The heavier +- **Funding**: a periodic payment that anchors the pool's risk. The heavier side of open interest pays funding to the pool over time. -- **Open interest** — the total notional size currently open on a side. -- **Liquidity provider** — a depositor who funds the pool and is the counterparty +- **Open interest**: the total notional size currently open on a side. +- **Liquidity provider**: a depositor who funds the pool and is the counterparty to every trade, earning fees in exchange for taking the other side of trader profit and loss. -- **Assets-under-management** — the marked value of liquidity-provider holdings: +- **Assets-under-management**: the marked value of liquidity-provider holdings: pool liquidity minus the aggregate unrealized profit traders are owed. -- **Liquidity-provider share** — a token representing a pro-rata claim on +- **Liquidity-provider share**: a token representing a pro-rata claim on assets-under-management. -- **Oracle feed** — the account the pool reads its price from. This example uses +- **Oracle feed**: the account the pool reads its price from. This example uses a mock oracle price feed; production points at a real one, such as a Pyth price feed. -- **Mark price** — the price positions are valued at. Here it is the oracle +- **Mark price**: the price positions are valued at. Here it is the oracle price directly, with no separate mark/index distinction. +- **Average price**: the pool's time-weighted moving average of the oracle + price (`average_price`), which follows the last ten minutes of prices. Each + oracle read credits the seconds since the previous read to the price that + read saw (`last_oracle_price`), so a price counts only from the read that + first sees it. Positions are never valued at it. +- **Price band**: the range around the average price, `max_price_deviation_bps` + wide on each side, outside which the pool refuses to open or close positions + or move liquidity. Liquidation is not refused. diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs index 07bedc4fc..706426f43 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs @@ -33,10 +33,18 @@ pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// lowers over time, so the window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; -/// Upper bound on the per-pool `max_leverage` parameter, so a pool cannot be -/// configured with an absurd leverage that makes every position instantly -/// liquidatable on the smallest price move. -pub const MAX_LEVERAGE_CEILING: u16 = 100; +/// How many seconds of oracle prices the pool's `average_price` follows. Each +/// fold moves the average toward the price seen at the previous read by +/// `elapsed / window` of the gap between them, and an interval of a full window +/// or more replaces the average with that price. Ten minutes is long enough +/// that a price seen at two reads six seconds apart, about as long as a faulty +/// or manipulated oracle print lasts, moves the average by one percent of its +/// jump, and short enough that a genuine move is back inside the band within +/// minutes of repeated reads. Counted on the Clock's `unix_timestamp`, +/// like funding: it is a span of wall-clock time, and the second or two of +/// leader drift changes a fold's weight by well under one percent. +#[constant] +pub const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; /// Upper bound on the per-pool `funding_rate_per_second` parameter, in /// `FUNDING_PRECISION` units: 277 billionths of a position's size per second, diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs index 3f33f0c2c..5a18aa401 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs @@ -14,8 +14,8 @@ pub enum PerpError { #[msg("Arithmetic overflow")] MathOverflow, - #[msg("Requested leverage exceeds the pool maximum")] - LeverageTooHigh, + #[msg("Position is too large for its collateral: net collateral is below the pool's initial margin")] + InitialMarginNotMet, #[msg("Pool parameter is outside the allowed range")] InvalidParameter, @@ -58,4 +58,13 @@ pub enum PerpError { #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, + + #[msg("Initial margin is at or below the maintenance margin: positions could open already liquidatable")] + InitialMarginNotAboveMaintenance, + + #[msg("Maximum price deviation is outside the allowed range: it must be above zero and below 10,000 basis points")] + InvalidPriceDeviation, + + #[msg("Oracle price is too far from the pool's average price: trading pauses until the average catches up")] + PriceOutsideBand, } diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs index 78af513ce..3ce6c4d3e 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding}; +use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; use crate::state::Pool; pub fn handle_add_liquidity( @@ -19,7 +19,7 @@ pub fn handle_add_liquidity( require!(amount > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply; let shares: u64 = if lp_supply == 0 && pool.liquidity == 0 { diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs index 61c741394..0d0b9a8d5 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs @@ -6,7 +6,9 @@ use anchor_spl::{ use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, settle_position}; +use crate::instructions::shared::{ + basis_points_of, refresh_price_and_funding_within_band, settle_position, +}; use crate::state::{Pool, Position}; pub fn handle_close_position( @@ -14,7 +16,7 @@ pub fn handle_close_position( minimum_payout: u64, ) -> Result<()> { let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let position = &context.accounts.position; let position_size = position.size; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs index f87dafa8b..a6c8ffe57 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -5,10 +5,10 @@ use anchor_spl::{ }; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING, - POOL_SEED, VAULT_SEED, + BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, POOL_SEED, VAULT_SEED, }; use crate::errors::PerpError; +use crate::state::oracle::read_oracle_price; use crate::state::Pool; /// Trading parameters set once at pool creation. None of them can be changed @@ -25,12 +25,22 @@ pub struct PoolParameters { pub open_fee_bps: u16, pub close_fee_bps: u16, - pub max_leverage: u16, + + /// Net collateral a position must post to open, in basis points of its + /// notional size. Must be above `maintenance_margin_bps` and at most + /// 10_000 (no leverage). + pub initial_margin_bps: u16, + pub maintenance_margin_bps: u16, pub liquidation_fee_bps: u16, /// Maximum oracle confidence band tolerated, in basis points of the price. pub max_confidence_bps: u16, + + /// Widest gap, in basis points of the pool's average price, between the + /// oracle price and that average at which positions may still open or + /// close and liquidity may still move. + pub max_price_deviation_bps: u16, } pub fn handle_initialize_pool( @@ -38,10 +48,6 @@ pub fn handle_initialize_pool( parameters: PoolParameters, ) -> Result<()> { let denominator = BASIS_POINTS_DENOMINATOR as u16; - require!( - parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING, - PerpError::InvalidParameter - ); // The rate never changes after this, so bounding it here bounds it for the // life of the pool. require!( @@ -75,12 +81,40 @@ pub fn handle_initialize_pool( parameters.maintenance_margin_bps > parameters.close_fee_bps, PerpError::InvalidParameter ); + // A position must open with more margin than it is liquidated at, or it + // could be liquidated in the same slot it opened. At most 100% of + // notional: more than that would demand collateral above the position's + // size. + require!( + parameters.initial_margin_bps > parameters.maintenance_margin_bps, + PerpError::InitialMarginNotAboveMaintenance + ); + require!( + parameters.initial_margin_bps <= denominator, + PerpError::InvalidParameter + ); // Zero would reject every real feed (which always reports some uncertainty); // above 100% is meaningless. Anything in between is a valid risk choice. require!( parameters.max_confidence_bps > 0 && parameters.max_confidence_bps < denominator, PerpError::InvalidParameter ); + // Zero would refuse every price move, however small. At 100% or more the + // band could never refuse a fall, since the oracle price is always + // positive. + require!( + parameters.max_price_deviation_bps > 0 && parameters.max_price_deviation_bps < denominator, + PerpError::InvalidPriceDeviation + ); + + // Seed the average with a validated oracle price, so the band is in force + // from the first trade. + let initial_price = read_oracle_price( + &context.accounts.oracle_feed, + parameters.oracle_scale, + parameters.max_confidence_bps, + )?; + let current_timestamp = Clock::get()?.unix_timestamp; let pool = &mut context.accounts.pool; pool.authority = context.accounts.authority.key(); @@ -98,14 +132,18 @@ pub fn handle_initialize_pool( pool.long_size_scaled = 0; pool.short_size_scaled = 0; pool.cumulative_funding = 0; - pool.last_funding_timestamp = Clock::get()?.unix_timestamp; + pool.last_funding_timestamp = current_timestamp; + pool.average_price = initial_price; + pool.last_oracle_price = initial_price; + pool.average_price_timestamp = current_timestamp; pool.funding_rate_per_second = parameters.funding_rate_per_second; pool.open_fee_bps = parameters.open_fee_bps; pool.close_fee_bps = parameters.close_fee_bps; - pool.max_leverage = parameters.max_leverage; + pool.initial_margin_bps = parameters.initial_margin_bps; pool.maintenance_margin_bps = parameters.maintenance_margin_bps; pool.liquidation_fee_bps = parameters.liquidation_fee_bps; pool.max_confidence_bps = parameters.max_confidence_bps; + pool.max_price_deviation_bps = parameters.max_price_deviation_bps; pool.bump = context.bumps.pool; Ok(()) @@ -128,8 +166,9 @@ pub struct InitializePoolAccountConstraints<'info> { pub collateral_mint: Box>, /// CHECK: The oracle feed account. Its key is stored on the pool and every - /// read validates the layout, scale, and freshness; it is never trusted by - /// type. Swap for a real Pyth price feed in production. + /// read, including the one here that seeds the average price, validates + /// the layout, scale, and freshness; it is never trusted by type. Swap for + /// a real Pyth price feed in production. pub oracle_feed: UncheckedAccount<'info>, /// Liquidity-provider share mint. The pool account is its mint authority diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs index 88337e1df..33c621dee 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs @@ -6,6 +6,7 @@ pub mod liquidate_position; pub mod open_position; pub mod remove_liquidity; pub mod shared; +pub mod update_price_average; pub use add_liquidity::*; pub use close_position::*; @@ -14,3 +15,4 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; +pub use update_price_average::*; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs index 45b27ac47..0e1554c0f 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs @@ -4,9 +4,11 @@ use anchor_spl::{ token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, }; -use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; +use crate::constants::{BASIS_POINTS_DENOMINATOR, POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, scale_size}; +use crate::instructions::shared::{ + basis_points_of, refresh_price_and_funding_within_band, scale_size, +}; use crate::state::{Pool, Position, Side}; pub fn handle_open_position( @@ -19,7 +21,7 @@ pub fn handle_open_position( require!(collateral_amount > 0 && size > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; // Slippage: a long must not fill above the caller's limit, a short not // below it. `0` opts out. @@ -32,21 +34,28 @@ pub fn handle_open_position( } // The open fee is taken out of the posted collateral; the rest backs the - // position. Leverage and margin are measured against this net collateral. + // position, and the initial margin is measured against this net collateral. let open_fee = basis_points_of(size, pool.open_fee_bps)?; let net_collateral = collateral_amount .checked_sub(open_fee) .ok_or(PerpError::InsufficientCollateral)?; require!(net_collateral > 0, PerpError::ZeroAmount); - let max_notional = (net_collateral as u128) - .checked_mul(pool.max_leverage as u128) + // Initial margin: net collateral must be at least `initial_margin_bps` of + // the notional size, compared as `net_collateral * 10_000 >= size * bps` + // so nothing is rounded. `initialize_pool` keeps the initial margin above + // the maintenance margin, so a position that passes this check opens with + // equity above the liquidation threshold. + let collateral_scaled = (net_collateral as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) .ok_or(PerpError::MathOverflow)?; - require!(size as u128 <= max_notional, PerpError::LeverageTooHigh); - - // Refuse a position that would open already inside the liquidation band. - let maintenance = basis_points_of(size, pool.maintenance_margin_bps)?; - require!(net_collateral > maintenance, PerpError::PositionNotHealthy); + let required_scaled = (size as u128) + .checked_mul(pool.initial_margin_bps as u128) + .ok_or(PerpError::MathOverflow)?; + require!( + collateral_scaled >= required_scaled, + PerpError::InitialMarginNotMet + ); // Reserve liquidity to cover this position's maximum recoverable profit // (its notional `size`). The reserve must be backed by liquidity-provider diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs index e35237279..500c119d7 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding}; +use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; use crate::state::Pool; pub fn handle_remove_liquidity( @@ -19,7 +19,7 @@ pub fn handle_remove_liquidity( require!(shares > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply; let aum = liquidity_provider_aum(pool, price)?; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs index af20cdfb4..53e9665e4 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs @@ -1,6 +1,8 @@ use anchor_lang::prelude::*; -use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; +use crate::constants::{ + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, PRICE_AVERAGE_WINDOW_SECONDS, SIZE_PRECISION, +}; use crate::errors::PerpError; use crate::state::{Pool, Position, Side}; @@ -216,16 +218,102 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { .map_err(|_| PerpError::MathOverflow.into()) } -/// The preamble every price-sensitive handler runs: read a validated oracle -/// price, then bring the pool's funding index up to the current time, so the -/// settlement that follows uses fresh numbers for both. Centralized so no -/// handler can settle a position against a stale funding index. +/// Fold the elapsed interval into the pool's `average_price`, then record +/// `price` as the latest observation. +/// +/// The interval since the last fold is credited to the price observed at +/// that fold, `last_oracle_price`, on the assumption that it held throughout: +/// +/// `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS` +/// +/// The price read now only starts counting from now, so it moves the average +/// only if it is still the oracle's price at a later read, weighted by the +/// seconds between the two reads; a read of a different price in between +/// replaces it. A pool left idle for a window or more therefore cannot have +/// its average set by one read. As with funding, a timestamp at or before the +/// stored one is treated as no time elapsed: the average and the stored stamp +/// stay where they are, and only `last_oracle_price` is updated. +pub fn fold_price_into_average(pool: &mut Pool, price: u64, current_timestamp: i64) -> Result<()> { + if current_timestamp <= pool.average_price_timestamp { + pool.last_oracle_price = price; + return Ok(()); + } + let elapsed = current_timestamp + .checked_sub(pool.average_price_timestamp) + .ok_or(PerpError::MathOverflow)?; + let weight = elapsed.min(PRICE_AVERAGE_WINDOW_SECONDS); + + let average = pool.average_price as i128; + // Multiply before dividing; the gap is signed, so the average moves down + // as readily as up. + let movement = (pool.last_oracle_price as i128) + .checked_sub(average) + .ok_or(PerpError::MathOverflow)? + .checked_mul(weight as i128) + .ok_or(PerpError::MathOverflow)? + .checked_div(PRICE_AVERAGE_WINDOW_SECONDS as i128) + .ok_or(PerpError::MathOverflow)?; + pool.average_price = average + .checked_add(movement) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + pool.last_oracle_price = price; + pool.average_price_timestamp = current_timestamp; + Ok(()) +} + +/// Refuse an oracle `price` more than `max_price_deviation_bps` away from the +/// pool's stored `average_price`: +/// `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. +pub fn require_price_within_band(pool: &Pool, price: u64) -> Result<()> { + let deviation_scaled = (price.abs_diff(pool.average_price) as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) + .ok_or(PerpError::MathOverflow)?; + let band_scaled = (pool.average_price as u128) + .checked_mul(pool.max_price_deviation_bps as u128) + .ok_or(PerpError::MathOverflow)?; + require!(deviation_scaled <= band_scaled, PerpError::PriceOutsideBand); + Ok(()) +} + +/// The preamble `liquidate_position` and `update_price_average` run: read a +/// validated oracle price, bring the pool's funding index up to the current +/// time, and fold the interval since the previous read into the pool's average +/// (see `fold_price_into_average`), so the settlement that follows uses fresh +/// numbers. Centralized so no handler can settle a position +/// against a stale funding index. +/// +/// No band check: liquidation has to keep working through a genuine price +/// move, because that is when positions go underwater, and +/// `update_price_average` is how the average catches up with one. pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountInfo) -> Result { - let price = crate::state::oracle::read_oracle_price( - oracle_feed, - pool.oracle_scale, - pool.max_confidence_bps, - )?; - accrue_funding(pool, Clock::get()?.unix_timestamp)?; + let price = read_pool_oracle_price(pool, oracle_feed)?; + apply_price_and_funding(pool, price)?; Ok(price) } + +/// The preamble for every handler that opens or closes a position or moves +/// liquidity: the same as `refresh_price_and_funding`, but first refuses a +/// price outside the band around the stored average, before anything is +/// folded in or the price is recorded. A single oracle print far from the +/// average therefore cannot open, close, deposit, or withdraw at that price. +pub fn refresh_price_and_funding_within_band( + pool: &mut Pool, + oracle_feed: &AccountInfo, +) -> Result { + let price = read_pool_oracle_price(pool, oracle_feed)?; + require_price_within_band(pool, price)?; + apply_price_and_funding(pool, price)?; + Ok(price) +} + +fn read_pool_oracle_price(pool: &Pool, oracle_feed: &AccountInfo) -> Result { + crate::state::oracle::read_oracle_price(oracle_feed, pool.oracle_scale, pool.max_confidence_bps) +} + +fn apply_price_and_funding(pool: &mut Pool, price: u64) -> Result<()> { + let current_timestamp = Clock::get()?.unix_timestamp; + accrue_funding(pool, current_timestamp)?; + fold_price_into_average(pool, price, current_timestamp) +} diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs new file mode 100644 index 000000000..8e46535bc --- /dev/null +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs @@ -0,0 +1,30 @@ +use anchor_lang::prelude::*; + +use crate::constants::POOL_SEED; +use crate::instructions::shared::refresh_price_and_funding; +use crate::state::Pool; + +pub fn handle_update_price_average( + context: Context, +) -> Result<()> { + refresh_price_and_funding(&mut context.accounts.pool, &context.accounts.oracle_feed)?; + Ok(()) +} + +#[derive(Accounts)] +pub struct UpdatePriceAverageAccountConstraints<'info> { + /// Anyone may update the average: the result depends only on the oracle + /// price and the clock, never on who calls. + pub caller: Signer<'info>, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + has_one = oracle_feed, + )] + pub pool: Box>, + + /// CHECK: validated by the `has_one = oracle_feed` constraint on the pool. + pub oracle_feed: UncheckedAccount<'info>, +} diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs index a7487816c..7c2bb1edb 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs @@ -1,9 +1,10 @@ use anchor_lang::prelude::*; mod constants; -mod errors; // Public so the LiteSVM integration tests can build instruction arguments -// (`PoolParameters`, `Side`) against the program's own types. +// (`PoolParameters`, `Side`) against the program's own types, and match +// failures against `PerpError` codes. +pub mod errors; pub mod instructions; pub mod state; @@ -75,6 +76,18 @@ pub mod perpetual_futures { instructions::handle_liquidate_position(context) } + /// Read the oracle, credit the seconds since the previous read to the + /// price that read saw, record the current price for the next read, and + /// accrue funding up to now. Permissionless: after a genuine price move + /// takes the oracle outside the pool's band, anyone can call this + /// repeatedly as time passes to walk the average toward the new price until + /// trading resumes. + pub fn update_price_average( + context: Context, + ) -> Result<()> { + instructions::handle_update_price_average(context) + } + /// The pool operator sweeps the accumulated program fees from the vault. pub fn collect_fees(context: Context) -> Result<()> { instructions::handle_collect_fees(context) diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs index 46f8831a7..365353024 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs @@ -71,6 +71,23 @@ pub struct Pool { /// the cluster's slot time. pub last_funding_timestamp: i64, + /// Time-weighted moving average of the oracle price, in the pool's + /// `oracle_scale` fixed point. Seeded with the oracle price when the pool is + /// created. Every handler that reads the oracle credits the seconds since + /// the previous read to `last_oracle_price`, the price that read saw. + /// Trading and liquidity handlers refuse an oracle price more than + /// `max_price_deviation_bps` away from it, so a sudden jump pauses them + /// until the average catches up. + pub average_price: u64, + + /// The oracle price at the most recent read, in `oracle_scale` fixed point. + /// The next read folds it into `average_price` for the seconds in between. + pub last_oracle_price: u64, + + /// The Clock's `unix_timestamp` of the most recent fold into + /// `average_price`. + pub average_price_timestamp: i64, + /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. pub funding_rate_per_second: u64, @@ -80,11 +97,13 @@ pub struct Pool { pub close_fee_bps: u16, - /// Highest leverage a position may open at (`size <= collateral * max`). - pub max_leverage: u16, + /// Net collateral a position must post to open, in basis points of its + /// notional size: 1_000 allows at most 10x leverage. Always above + /// `maintenance_margin_bps`, so no position opens already liquidatable. + pub initial_margin_bps: u16, - /// Equity threshold, in basis points of notional, below which a position is - /// liquidatable. + /// Equity threshold, in basis points of notional, at or below which a + /// position is liquidatable. pub maintenance_margin_bps: u16, /// Reward paid to a liquidator, in basis points of the liquidated notional. @@ -94,6 +113,10 @@ pub struct Pool { /// pool will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, + /// Widest gap the pool trades across between the oracle price and + /// `average_price`, in basis points of `average_price`. + pub max_price_deviation_bps: u16, + /// Bump of this account's own address. The pool owns the custody vault /// and is the LP mint's authority, so it signs vault transfers and /// mint/burn CPIs with `[POOL_SEED, collateral_mint, oracle_feed, bump]`; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs index 628769641..ef1d5dfda 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -4,7 +4,11 @@ use { AccountDeserialize, InstructionData, ToAccountMetas, }, litesvm::LiteSVM, - perpetual_futures::{instructions::initialize_pool::PoolParameters, state::Pool, state::Side}, + perpetual_futures::{ + errors::PerpError, + instructions::initialize_pool::PoolParameters, + state::{Pool, Position, Side}, + }, solana_keypair::Keypair, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, @@ -17,6 +21,9 @@ use { // Matches `MAX_FUNDING_RATE_PER_SECOND` in the program's constants: the // steepest funding rate `initialize_pool` accepts. const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; +// Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds +// replaces the pool's average price with the oracle price. +const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; // Ten years, in seconds. const TEN_YEARS: i64 = 315_360_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 @@ -52,6 +59,37 @@ fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } +/// The parameters every test market uses unless a test overrides one: 0.1% +/// open and close fees, a 10% initial margin (10x leverage), a 5% maintenance +/// margin, a 1% liquidation fee, a 1% maximum confidence band, and a 20% price +/// band around the pool's average price. +fn default_parameters(funding_rate_per_second: u64) -> PoolParameters { + PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + initial_margin_bps: 1_000, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + max_price_deviation_bps: 2_000, + } +} + +/// Assert that `result` failed with the program's `expected` error. Anchor +/// reports a program error as `Custom(6000 + the variant's index)`. +fn assert_fails_with(result: Result, expected: PerpError) { + let code = expected as u32 + 6000; + let Err(error) = result else { + panic!("the transaction should have failed with error code {code}"); + }; + assert!( + error.contains(&format!("Custom({code})")), + "expected error code {code}, got: {error}" + ); +} + /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -69,23 +107,14 @@ impl Market { /// funding rate. The admin is both the pool operator and the oracle feed /// authority. fn new(initial_price: i128, funding_rate_per_second: u64) -> Market { - let parameters = PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - open_fee_bps: 10, - close_fee_bps: 10, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, - }; - Market::try_new(initial_price, parameters).expect("pool initialization should succeed") + Market::try_new(initial_price, default_parameters(funding_rate_per_second)) + .expect("pool initialization should succeed") } /// Like `new`, but takes the full parameter set and surfaces an /// `initialize_pool` rejection instead of panicking, so tests can probe the /// parameter validation. - fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { + fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { let mut svm = LiteSVM::new(); svm.add_program( perpetual_futures::id(), @@ -167,7 +196,7 @@ impl Market { &[&admin], &admin.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; Ok(Market { svm, @@ -275,7 +304,7 @@ impl Market { provider_collateral: Pubkey, amount: u64, minimum_shares_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -305,8 +334,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn remove_liquidity( @@ -315,7 +343,7 @@ impl Market { provider_collateral: Pubkey, shares: u64, minimum_amount_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -345,8 +373,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn position_pda(&self, owner: &Pubkey, side: Side) -> Pubkey { @@ -369,7 +396,7 @@ impl Market { collateral_amount: u64, size: u64, acceptable_price: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -400,8 +427,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn close_position( @@ -410,7 +436,7 @@ impl Market { trader_collateral: Pubkey, side: Side, minimum_payout: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -435,8 +461,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn liquidate( @@ -445,7 +470,7 @@ impl Market { owner: &Pubkey, owner_collateral: Pubkey, side: Side, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(owner, side); let liquidator_collateral = derive_ata(&liquidator.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( @@ -473,11 +498,10 @@ impl Market { &[liquidator], &liquidator.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } - fn collect_fees(&mut self, authority: &Keypair) -> Result<(), ()> { + fn collect_fees(&mut self, authority: &Keypair) -> Result<(), String> { let authority_collateral = derive_ata(&authority.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -500,8 +524,40 @@ impl Market { &[authority], &authority.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) + } + + fn update_price_average(&mut self, caller: &Keypair) -> Result<(), String> { + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::UpdatePriceAverage {}.data(), + perpetual_futures::accounts::UpdatePriceAverageAccountConstraints { + caller: caller.pubkey(), + pool: self.pool, + oracle_feed: self.feed, + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[caller], + &caller.pubkey(), + ) + .map_err(|error| format!("{error:?}")) + } + + /// Hold the oracle at `price` while the pool's average catches up with + /// it: one update records `price` as the latest observation, then a full + /// averaging window passes with the price republished so it is fresh, and + /// a second update credits that window to `price`. A price more than the + /// band away from the average cannot be traded at until this has run. + fn settle_average_at(&mut self, price: i128) { + let caller = self.payer.insecure_clone(); + self.update_price_average(&caller).unwrap(); + self.pass_seconds(PRICE_AVERAGE_WINDOW_SECONDS); + self.set_price(price); + self.update_price_average(&caller).unwrap(); } /// Deposit a large amount of liquidity so the pool can pay trader profits, @@ -523,7 +579,17 @@ fn test_initialize_pool() { assert_eq!(pool.collateral_mint, market.collateral_mint); assert_eq!(pool.oracle_feed, market.feed); assert_eq!(pool.oracle_scale, ORACLE_SCALE); - assert_eq!(pool.max_leverage, 10); + assert_eq!(pool.initial_margin_bps, 1_000); + assert_eq!(pool.max_price_deviation_bps, 2_000); + // The average starts at the oracle price the pool was created against. + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!( + pool.average_price_timestamp, + market + .svm + .get_sysvar::() + .unix_timestamp + ); assert_eq!(pool.liquidity, 0); assert_eq!(pool.total_collateral, 0); @@ -849,17 +915,53 @@ fn test_open_rejects_zero_amounts() { } #[test] -fn test_open_rejects_excess_leverage() { +fn test_open_rejects_position_below_initial_margin() { let mut market = Market::default_market(); market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); + let (trader, trader_collateral) = market.funded_trader(2_000 * ONE_USDC); - // max_leverage is 10x; 11x must be rejected. - let size = 11_000 * ONE_USDC; - assert!(market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .is_err()); + // The initial margin is 10% of notional. 1,000 USDC of collateral less + // the 11 USDC open fee leaves 989 USDC, short of the 1,100 USDC an 11,000 + // USDC position needs. + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + 1_000 * ONE_USDC, + 11_000 * ONE_USDC, + 0, + ), + PerpError::InitialMarginNotMet, + ); + + // A 10,000 USDC position needs 1,000 USDC net of its 10 USDC open fee. + // One minor unit short of 1,010 USDC is refused, and exactly 1,010 USDC + // opens at 10x. + let size = 10_000 * ONE_USDC; + let exact_collateral = 1_010 * ONE_USDC; + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + exact_collateral - 1, + size, + 0, + ), + PerpError::InitialMarginNotMet, + ); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + exact_collateral, + size, + 0, + ) + .unwrap(); + assert_eq!(market.pool_state().total_collateral, size / 10); } #[test] @@ -1061,18 +1163,18 @@ fn test_funding_follows_seconds_not_slots() { #[test] fn test_initialize_pool_rejects_funding_rate_above_the_maximum() { // The rate is fixed at creation, so this is the only place it is checked. - let parameters = |funding_rate_per_second| PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - open_fee_bps: 10, - close_fee_bps: 10, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, - }; - assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND + 1)).is_err()); - assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND)).is_ok()); + assert_fails_with( + Market::try_new( + dollars(100), + default_parameters(MAX_FUNDING_RATE_PER_SECOND + 1), + ), + PerpError::InvalidParameter, + ); + assert!(Market::try_new( + dollars(100), + default_parameters(MAX_FUNDING_RATE_PER_SECOND) + ) + .is_ok()); } /// The pool operator trading against their own pool. The lighter side of open @@ -1300,8 +1402,11 @@ fn test_profit_capped_at_reserved_notional() { .unwrap(); // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). + // profit is capped at the reserved notional (`size`). A move this large is + // far outside the price band, so the average has to catch up before the + // position can close. market.set_price(dollars(300)); + market.settle_average_at(dollars(300)); market .close_position(&trader, trader_collateral, Side::Long, 0) .unwrap(); @@ -1350,14 +1455,304 @@ fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. let parameters = PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second: 0, - open_fee_bps: 10, close_fee_bps: 600, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, + ..default_parameters(0) + }; + assert_fails_with( + Market::try_new(dollars(100), parameters), + PerpError::InvalidParameter, + ); +} + +#[test] +fn test_initialize_pool_rejects_initial_margin_at_or_below_maintenance() { + // An initial margin at or below the 5% maintenance margin would let a + // position open already liquidatable. + let with_initial_margin = |initial_margin_bps| PoolParameters { + initial_margin_bps, + ..default_parameters(0) + }; + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(500)), + PerpError::InitialMarginNotAboveMaintenance, + ); + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(350)), + PerpError::InitialMarginNotAboveMaintenance, + ); + + // Above 100% of notional is refused too. One basis point above the + // maintenance margin, and exactly 100%, are accepted. + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(10_001)), + PerpError::InvalidParameter, + ); + assert!(Market::try_new(dollars(100), with_initial_margin(501)).is_ok()); + assert!(Market::try_new(dollars(100), with_initial_margin(10_000)).is_ok()); +} + +#[test] +fn test_initialize_pool_rejects_price_deviation_outside_range() { + let with_deviation = |max_price_deviation_bps| PoolParameters { + max_price_deviation_bps, + ..default_parameters(0) }; - assert!(Market::try_new(dollars(100), parameters).is_err()); + for rejected in [0, 10_000] { + assert_fails_with( + Market::try_new(dollars(100), with_deviation(rejected)), + PerpError::InvalidPriceDeviation, + ); + } + assert!(Market::try_new(dollars(100), with_deviation(1)).is_ok()); + assert!(Market::try_new(dollars(100), with_deviation(9_999)).is_ok()); +} + +/// A single oracle print far from the pool's average cannot be traded at: the +/// open is refused before the price is folded into the average. +#[test] +fn test_open_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + + // The band is 20% around the $100 average: $125 and $79 are outside it. + for outside_price in [dollars(125), dollars(79)] { + market.set_price(outside_price); + // The two refused opens are otherwise byte-identical transactions. + market.svm.expire_blockhash(); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); + // The refused open folded nothing into the average. + assert_eq!(market.pool_state().average_price, dollars(100) as u64); + } + + // $118 is inside the band, and opens at that price. + market.set_price(dollars(118)); + market.svm.expire_blockhash(); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + let position_account = market + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .unwrap(); + let position = Position::try_deserialize(&mut position_account.data.as_slice()).unwrap(); + assert_eq!(position.entry_price, dollars(118) as u64); +} + +#[test] +fn test_close_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + // A jump to $125 would pay the long $1,250, but $125 is 25% from the + // $100 average, outside the 20% band. + market.set_price(dollars(125)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::PriceOutsideBand, + ); + + // At $115, inside the band, the close goes through and pays the 15% gain. + market.set_price(dollars(115)); + market.svm.expire_blockhash(); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + let fee = size / 1_000; + let profit = size * 15 / 100; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee + profit - fee + ); +} + +/// Liquidation has no band check: a genuine crash is when positions go +/// underwater, so the pool has to be able to liquidate through one. +#[test] +fn test_liquidation_runs_outside_band() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_100 * ONE_USDC; + let size = 10_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + // $75 is 25% below the $100 average, so the owner cannot close there. + market.set_price(dollars(75)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::PriceOutsideBand, + ); + + let liquidator = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + assert!(market + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .is_none()); + assert_eq!(market.pool_state().long_size, 0); +} + +#[test] +fn test_liquidity_changes_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + let (provider, provider_collateral) = market.seed_liquidity(10_000 * ONE_USDC); + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + + // $76 is 24% below the $100 average. + market.set_price(dollars(76)); + let (depositor, depositor_collateral) = market.funded_trader(5_000 * ONE_USDC); + assert_fails_with( + market.add_liquidity(&depositor, depositor_collateral, 5_000 * ONE_USDC, 0), + PerpError::PriceOutsideBand, + ); + assert_fails_with( + market.remove_liquidity(&provider, provider_collateral, shares, 0), + PerpError::PriceOutsideBand, + ); +} + +/// After a genuine move outside the band, anyone can walk the average toward +/// the new price with `update_price_average`, and trading resumes once the +/// price is back inside the band. +#[test] +fn test_price_average_catches_up_after_genuine_move() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + + // NVDAx reprices from $100 to $130, 30% away from the average. + let new_price = dollars(130); + market.set_price(new_price); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); + + // Every two minutes the keeper calls `update_price_average`. Each call + // credits the two minutes since the previous read to the price that read + // saw, a fifth of the window. The first call credits $100, the price + // before the move, and records $130; each later call moves the average a + // fifth of the remaining gap to $130: $100, then $106, then $110.80. $130 + // is within 20% of any average from $108.34 up, so the third update + // reopens trading. + let mut updates = 0; + loop { + market.pass_seconds(120); + market.set_price(new_price); + market.update_price_average(&keeper).unwrap(); + updates += 1; + let opened = + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0); + if opened.is_ok() { + break; + } + assert_fails_with(opened, PerpError::PriceOutsideBand); + assert!(updates < 10, "the average never caught up"); + } + assert_eq!(updates, 3); + let pool = market.pool_state(); + assert_eq!(pool.average_price, 11_080_000_000); + assert_eq!(pool.last_oracle_price, new_price as u64); +} + +#[test] +fn test_single_update_moves_average_by_elapsed_fraction() { + let mut market = Market::default_market(); + let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + let created_at = market.pool_state().average_price_timestamp; + + // The first update after the oracle moves to $115 credits the four + // minutes since creation to $100, the price seen at creation, so the + // average stays at $100 and $115 is recorded for the next read. + market.pass_seconds(240); + market.set_price(dollars(115)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!(pool.last_oracle_price, dollars(115) as u64); + assert_eq!(pool.average_price_timestamp, created_at + 240); + + // Four more minutes at $115 are 240 of the 600-second window, so the next + // update moves the average 240/600 of the way from $100 to $115: to $106. + market.pass_seconds(240); + market.set_price(dollars(115)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(106) as u64); + assert_eq!(pool.average_price_timestamp, created_at + 480); + + // Fifteen minutes is more than a full window, so the next update replaces + // the average with $115, the price at the previous read, and records the + // fall to $97. One more update credits $97 for a full window. + market.pass_seconds(900); + market.set_price(dollars(97)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(115) as u64); + assert_eq!(pool.last_oracle_price, dollars(97) as u64); + market.pass_seconds(900); + market.set_price(dollars(97)); + market.update_price_average(&keeper).unwrap(); + assert_eq!(market.pool_state().average_price, dollars(97) as u64); +} + +/// A pool left idle for more than a window cannot have its average set by one +/// read of a manipulated price. The read only records the price; the interval +/// before it is credited to the price seen at the read before. Once a read of +/// the real price replaces it, the manipulated price has moved the average +/// only by the seconds between the two reads. +#[test] +fn test_one_manipulated_read_after_idle_does_not_move_average() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + let attacker = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + + // Fifteen idle minutes, then the oracle is pushed to $160 and the + // attacker calls `update_price_average`. The average stays at $100. + market.pass_seconds(900); + market.set_price(dollars(160)); + market.update_price_average(&attacker).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!(pool.last_oracle_price, dollars(160) as u64); + + // Six seconds later the oracle is back at $100 and is read again. The six + // seconds are credited to $160: the average moves 6/600 of the $60 gap, + // to $100.60, and $100 replaces $160 as the latest observation. + market.pass_seconds(6); + market.set_price(dollars(100)); + market.update_price_average(&attacker).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, 10_060_000_000); + assert_eq!(pool.last_oracle_price, dollars(100) as u64); + + // An open at $160 is still refused. + market.set_price(dollars(160)); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); } diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index d878f5d8d..80b6e1eed 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +## 2026-10-01 + +Replace the leverage cap with an initial margin. `max_leverage` on +`PoolParameters` and `Pool` is now `initial_margin_bps`, the net collateral a +position must post to open, in basis points of its size (1,000 is 10x). +`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= +10_000`, refusing an initial margin at or below the maintenance margin with the +new `InitialMarginNotAboveMaintenance` and one above 10,000 with +`InvalidParameter`; `MAX_LEVERAGE_CEILING` is removed. `open_position` checks +`net_collateral * 10_000 >= size * initial_margin_bps` and fails with +`InitialMarginNotMet`, which takes `LeverageTooHigh`'s place and its error code +(6004). Its separate check that a new position starts above the maintenance +margin is removed, because the initial margin implies it; `PositionNotHealthy` +remains for `close_position`. + +Add a price band around a program-maintained average price. A fresh, confident +oracle print could still be wrong, and every handler traded at it. The pool now +keeps `average_price`, a time-weighted moving average of the oracle price, +`last_oracle_price`, the price at the most recent oracle read, and +`average_price_timestamp`. `initialize_pool` seeds the average and +`last_oracle_price` from the oracle. Every handler that reads the oracle credits +the seconds since the previous read to the price that read saw, +`average += (last_oracle_price - average) * min(elapsed, +PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new +constant at 600 seconds, and then records the price it read as +`last_oracle_price`. The price read now only counts from now, so a pool left +idle for a window or more cannot have its average set by one read of a +manipulated price: that price moves the average only if the oracle still shows +it at a later read, weighted by the seconds between the two reads. +`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse +a price outside `|price - average_price| * 10_000 <= average_price * +max_price_deviation_bps` with the new `PriceOutsideBand`, checked against the +stored average before anything is folded in. `liquidate_position` folds and +records without the check. The new permissionless `update_price_average` +handler folds and records too, also without the check, so keepers calling it +repeatedly as time passes can walk the average to a genuine move. `max_price_deviation_bps` is a new +`PoolParameters` field, which `initialize_pool` requires to be above zero and +below 10,000 with the new `InvalidPriceDeviation`. `shared.rs` has +`refresh_price_and_funding_within_band` for the four band-checked handlers +beside `refresh_price_and_funding` for the other two. The `errors` module is +public so the tests can match `PerpError` codes. + +Tested by `test_open_rejects_position_below_initial_margin` (formerly +`test_open_rejects_excess_leverage`, now checking both sides of the boundary), +`test_initialize_pool_rejects_initial_margin_at_or_below_maintenance`, +`test_initialize_pool_rejects_price_deviation_outside_range`, +`test_open_rejected_when_oracle_jumps_outside_band`, +`test_close_rejected_when_oracle_jumps_outside_band`, +`test_liquidity_changes_rejected_when_oracle_jumps_outside_band`, +`test_liquidation_runs_outside_band`, +`test_price_average_catches_up_after_genuine_move`, +`test_single_update_moves_average_by_elapsed_fraction` and +`test_one_manipulated_read_after_idle_does_not_move_average`. The default test market +uses a 1,000 basis point initial margin and a 2,000 basis point band; +`test_profit_capped_at_reserved_notional` triples the price, far outside the +band, so it now calls `update_price_average` to record the new price, lets a +full window pass, and calls it again before closing. + ## 2026-09-30 Remove `set_funding_rate`. The pool's authority could change the funding rate at diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index f048883b9..2551e37dc 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -29,7 +29,7 @@ All arithmetic is integer `u128` with `checked_*` operations, multiplying before ### Long and short, leverage, collateral -A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size up to the pool's maximum [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power). The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: +A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size, and the pool's [initial margin](https://www.investopedia.com/terms/i/initialmargin.asp) caps their [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power): `open_position` requires the collateral left after the open fee to be at least `initial_margin_bps` of the size, checked as `net_collateral * 10_000 >= size * initial_margin_bps`, and fails with `InitialMarginNotMet` otherwise. An initial margin of 1,000 basis points (10%) allows at most 10× leverage. The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: ``` long profit/loss = size * (price - entry_price) / entry_price @@ -54,10 +54,25 @@ Funding runs on the wall clock rather than the slot count, so what a position co A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless: anyone can crank it and earn the liquidation fee. +`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= 10_000` and refuses anything else with `InitialMarginNotAboveMaintenance` (or `InvalidParameter` above 10,000). Every position therefore opens with more margin than it is liquidated at, so none can be liquidated in the slot it opened. + ### Oracle The mark price comes from an oracle feed. This example validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. +### Price band + +A single oracle print can be wrong while still being fresh, positive and confident: a publisher fault, or a thin market moved for a few seconds. To stop anyone trading against such a print, the pool keeps its own time-weighted moving average of the oracle price, `Pool.average_price`, and refuses prices too far from it. + +- `initialize_pool` reads the oracle and seeds both `average_price` and `last_oracle_price` with its price, stamping `average_price_timestamp` with the Clock's `unix_timestamp`. +- Every handler that reads the oracle credits the seconds since the previous read to the price that read saw, `last_oracle_price`, on the assumption that it held throughout: `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`. It then records the price it read as the new `last_oracle_price`. The window is 600 seconds, so a price seen at two reads six seconds apart moves the average by 1% of its gap from the average, and an interval of ten minutes or more replaces the average with the price seen at its start. +- The price read now only starts counting from now. A manipulated price moves the average only if the oracle still shows it at a later read, and only by the seconds between the two reads; a read of the real price in between replaces it. A pool left idle for longer than the window therefore cannot have its average set by a single read. +- `open_position`, `close_position`, `add_liquidity` and `remove_liquidity` first check the price against the stored average, before anything is folded in: `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. A price outside that band fails with `PriceOutsideBand`, and the pool is left unchanged. +- `liquidate_position` folds and records without the band check. A genuine crash is when positions go underwater, so liquidation keeps working through one. +- `update_price_average()` is permissionless: any signer passes the pool and its oracle feed, and the handler reads and validates the oracle with the same checks, accrues funding, folds the elapsed interval in and records the price, with no band check. After a genuine move takes the oracle outside the band, keepers call it repeatedly as time passes: the first call records the new price, and each later call credits the time since the previous one to it, until the average is close enough to the price for trading to resume. + +`max_price_deviation_bps` is fixed by `initialize_pool`, which refuses zero (every move would be refused) and 10,000 or more (a fall could never be refused, since prices are positive) with `InvalidPriceDeviation`. + ### Fees and slippage Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional and accrue to the program. Every state-changing handler takes a `minimum_*` / acceptable-price bound (protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill) and reverts if the bound is breached. Pass `0` to opt out. @@ -74,7 +89,7 @@ Open and close fees are charged in [basis points](https://www.investopedia.com/t - **Bob** (Short trader): He thinks NVDA will fall and wants to profit from the downside. - **Dave** (Liquidator): Runs a bot that closes under-margined positions to earn the liquidation fee. -Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). The pool is configured with 10× max leverage, 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, and a 1% maximum oracle confidence band. +Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). The pool is configured with a 10% initial margin (10× leverage), 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, a 1% maximum oracle confidence band, and a 20% price band around its average price. --- @@ -82,9 +97,11 @@ Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). T **Instruction:** `initialize_pool(parameters)` +The handler validates the parameters, then reads the oracle once to seed the pool's average price at $100. + **Accounts created:** -- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA +- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, average oracle price, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA - `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA, seeds `["vault", pool]`: all USDC, both provider liquidity and trader collateral; `pool` is its owner - `lp_mint` PDA, seeds `["lp_mint", pool]`: the share [mint](https://solana.com/docs/terminology#mint-account); `pool` is the mint authority @@ -137,7 +154,7 @@ While both are open, **funding** accrues to the pool from the heavier side; it i **Instruction:** `close_position(minimum_payout)` -Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reserve cap), minus the $5 close fee. +$116 is 16% above the pool's $100 average price, inside the 20% band, so the close goes through. Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reserve cap), minus the $5 close fee. **Accounts modified:** @@ -145,6 +162,8 @@ Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reser - `Pool.reserved_liquidity`: −$5,000 (reserve released) - `Pool.total_collateral`: −$995 - `Pool.program_fees`: +$5 +- `Pool.average_price`: credits the time since the last read to $100, the price that read saw, so it stays at $100 +- `Pool.last_oracle_price`: $100 → $116, which the next read credits for the time in between - long open-interest accumulators: −= this position - `custody_vault` → `alice_usdc`: pays out $1,790 (net collateral + profit − close fee) - `Position` (Alice): closed; rent returned to Alice @@ -195,7 +214,7 @@ The genuinely hard part of a perpetual-futures venue is keeping it solvent and p - **Account-local safety**: "every favorable action refreshes the account's full active portfolio first; … stale … legs fail closed." Here, every position and liquidity action reads a fresh oracle (stale or wide-confidence prices are rejected) and recomputes pool exposure before any payout. - **Bounded progress**: "no public instruction needs to evaluate the whole market." Here, assets-under-management comes from running per-side accumulators, and liquidation acts on one position at a time, so no handler's cost grows with the number of open positions. -What production pool-perps (`solana-labs/perpetuals`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, and using the oracle's EMA for a less manipulable mark. +What production pool-perps (`solana-labs/perpetuals`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, and valuing positions at the oracle's EMA rather than its spot price. This example keeps its own average only to decide when to refuse trading, and values positions at the spot price. --- @@ -212,7 +231,18 @@ This is a teaching example, not an audited exchange. Notably: ## Testing -The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price, pre-restart-price, and wide-confidence rejection, funding accrual, funding-rate retuning (including that it settles elapsed seconds at the old rate, and that only the authority may call it), funding that follows seconds rather than slots, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. +The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover: + +- liquidity round-trips, and share inflation through a provider's own trades +- opening and closing longs and shorts in profit and loss +- the initial margin on both sides of its boundary, and slippage rejection +- stale-price, pre-restart-price, and wide-confidence rejection +- funding accrual, the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots +- the price band: opens, closes, deposits and withdrawals refused when the oracle jumps outside it (`test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band`, `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`), liquidation running outside it (`test_liquidation_runs_outside_band`), the exact average after each `update_price_average` (`test_single_update_moves_average_by_elapsed_fraction`), repeated updates walking the average to a genuine move until trading resumes (`test_price_average_catches_up_after_genuine_move`), and one manipulated read after an idle window leaving the average where it was (`test_one_manipulated_read_after_idle_does_not_move_average`) +- liquidation, and the refusal to liquidate a healthy position +- reserved-liquidity behaviour: profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity +- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin and a price band outside its range +- fee collection ```bash anchor build diff --git a/finance/perpetual-futures/anchor/TERMINOLOGY.md b/finance/perpetual-futures/anchor/TERMINOLOGY.md index 26c7bd5e0..55493ce89 100644 --- a/finance/perpetual-futures/anchor/TERMINOLOGY.md +++ b/finance/perpetual-futures/anchor/TERMINOLOGY.md @@ -2,36 +2,47 @@ Terms used in this example, in the sense they carry here. -- **Perpetual future (perp)** — a leveraged derivative position with no expiry +- **Perpetual future (perp)**: a leveraged derivative position with no expiry and no settlement date. Profit and loss is paid in the collateral token as the oracle price moves. -- **Long / short** — a long profits when the price rises, a short when it falls. +- **Long / short**: a long profits when the price rises, a short when it falls. Each is the opposite side of the pool's exposure. -- **Collateral** — the token a trader posts to back a position, and the token +- **Collateral**: the token a trader posts to back a position, and the token liquidity providers deposit. One pool uses one collateral token. -- **Notional size** — the position's exposure in collateral units. Profit and +- **Notional size**: the position's exposure in collateral units. Profit and loss scales with the notional, not with the collateral posted. -- **Leverage** — notional size divided by collateral. A pool caps it at - `max_leverage`. -- **Equity** — a position's current worth: net collateral plus unrealized profit +- **Leverage**: notional size divided by collateral. A pool caps it through its + initial margin: 1,000 basis points (10%) allows at most 10x. +- **Initial margin**: the net collateral, as a fraction of notional size, a + position must post to open (`initial_margin_bps`). Always above the + maintenance margin, so no position opens already liquidatable. +- **Equity**: a position's current worth: net collateral plus unrealized profit and loss, minus accrued funding. When equity falls to the maintenance margin, the position is liquidatable. -- **Maintenance margin** — the minimum equity, as a fraction of notional size, +- **Maintenance margin**: the minimum equity, as a fraction of notional size, a position must keep to avoid liquidation. -- **Liquidation** — closing an under-margined position. Permissionless here: any +- **Liquidation**: closing an under-margined position. Permissionless here: any caller can trigger it and earns the liquidation fee. -- **Funding** — a periodic payment that anchors the pool's risk. The heavier +- **Funding**: a periodic payment that anchors the pool's risk. The heavier side of open interest pays funding to the pool over time. -- **Open interest** — the total notional size currently open on a side. -- **Liquidity provider** — a depositor who funds the pool and is the counterparty +- **Open interest**: the total notional size currently open on a side. +- **Liquidity provider**: a depositor who funds the pool and is the counterparty to every trade, earning fees in exchange for taking the other side of trader profit and loss. -- **Assets-under-management** — the marked value of liquidity-provider holdings: +- **Assets-under-management**: the marked value of liquidity-provider holdings: pool liquidity minus the aggregate unrealized profit traders are owed. -- **Liquidity-provider share** — a token representing a pro-rata claim on +- **Liquidity-provider share**: a token representing a pro-rata claim on assets-under-management. -- **Oracle feed** — the account the pool reads its price from. This example uses +- **Oracle feed**: the account the pool reads its price from. This example uses a mock oracle price feed; production points at a real one, such as a Pyth price feed. -- **Mark price** — the price positions are valued at. Here it is the oracle +- **Mark price**: the price positions are valued at. Here it is the oracle price directly, with no separate mark/index distinction. +- **Average price**: the pool's time-weighted moving average of the oracle + price (`average_price`), which follows the last ten minutes of prices. Each + oracle read credits the seconds since the previous read to the price that + read saw (`last_oracle_price`), so a price counts only from the read that + first sees it. Positions are never valued at it. +- **Price band**: the range around the average price, `max_price_deviation_bps` + wide on each side, outside which the pool refuses to open or close positions + or move liquidity. Liquidation is not refused. diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs index 07bedc4fc..706426f43 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs @@ -33,10 +33,18 @@ pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// lowers over time, so the window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; -/// Upper bound on the per-pool `max_leverage` parameter, so a pool cannot be -/// configured with an absurd leverage that makes every position instantly -/// liquidatable on the smallest price move. -pub const MAX_LEVERAGE_CEILING: u16 = 100; +/// How many seconds of oracle prices the pool's `average_price` follows. Each +/// fold moves the average toward the price seen at the previous read by +/// `elapsed / window` of the gap between them, and an interval of a full window +/// or more replaces the average with that price. Ten minutes is long enough +/// that a price seen at two reads six seconds apart, about as long as a faulty +/// or manipulated oracle print lasts, moves the average by one percent of its +/// jump, and short enough that a genuine move is back inside the band within +/// minutes of repeated reads. Counted on the Clock's `unix_timestamp`, +/// like funding: it is a span of wall-clock time, and the second or two of +/// leader drift changes a fold's weight by well under one percent. +#[constant] +pub const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; /// Upper bound on the per-pool `funding_rate_per_second` parameter, in /// `FUNDING_PRECISION` units: 277 billionths of a position's size per second, diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs index 3f33f0c2c..5a18aa401 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs @@ -14,8 +14,8 @@ pub enum PerpError { #[msg("Arithmetic overflow")] MathOverflow, - #[msg("Requested leverage exceeds the pool maximum")] - LeverageTooHigh, + #[msg("Position is too large for its collateral: net collateral is below the pool's initial margin")] + InitialMarginNotMet, #[msg("Pool parameter is outside the allowed range")] InvalidParameter, @@ -58,4 +58,13 @@ pub enum PerpError { #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, + + #[msg("Initial margin is at or below the maintenance margin: positions could open already liquidatable")] + InitialMarginNotAboveMaintenance, + + #[msg("Maximum price deviation is outside the allowed range: it must be above zero and below 10,000 basis points")] + InvalidPriceDeviation, + + #[msg("Oracle price is too far from the pool's average price: trading pauses until the average catches up")] + PriceOutsideBand, } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs index 7cbd26bee..95955d80f 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding}; +use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; use crate::state::Pool; pub fn handle_add_liquidity( @@ -19,7 +19,7 @@ pub fn handle_add_liquidity( require!(amount > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply(); let shares: u64 = if lp_supply == 0 && pool.liquidity == 0 { diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs index bbfebd9bb..5440a8b73 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs @@ -6,7 +6,9 @@ use anchor_spl::{ use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, settle_position}; +use crate::instructions::shared::{ + basis_points_of, refresh_price_and_funding_within_band, settle_position, +}; use crate::state::{Pool, Position}; pub fn handle_close_position( @@ -14,7 +16,7 @@ pub fn handle_close_position( minimum_payout: u64, ) -> Result<()> { let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let position = &context.accounts.position; let position_size = position.size; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs index df3660d33..2c096339c 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -7,10 +7,10 @@ use anchor_spl::{ }; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING, - POOL_SEED, VAULT_SEED, + BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, POOL_SEED, VAULT_SEED, }; use crate::errors::PerpError; +use crate::state::oracle::read_oracle_price; use crate::state::Pool; /// Trading parameters set once at pool creation. None of them can be changed @@ -27,12 +27,22 @@ pub struct PoolParameters { pub open_fee_bps: u16, pub close_fee_bps: u16, - pub max_leverage: u16, + + /// Net collateral a position must post to open, in basis points of its + /// notional size. Must be above `maintenance_margin_bps` and at most + /// 10_000 (no leverage). + pub initial_margin_bps: u16, + pub maintenance_margin_bps: u16, pub liquidation_fee_bps: u16, /// Maximum oracle confidence band tolerated, in basis points of the price. pub max_confidence_bps: u16, + + /// Widest gap, in basis points of the pool's average price, between the + /// oracle price and that average at which positions may still open or + /// close and liquidity may still move. + pub max_price_deviation_bps: u16, } pub fn handle_initialize_pool( @@ -40,10 +50,6 @@ pub fn handle_initialize_pool( parameters: PoolParameters, ) -> Result<()> { let denominator = BASIS_POINTS_DENOMINATOR as u16; - require!( - parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING, - PerpError::InvalidParameter - ); // The rate never changes after this, so bounding it here bounds it for the // life of the pool. require!( @@ -77,12 +83,40 @@ pub fn handle_initialize_pool( parameters.maintenance_margin_bps > parameters.close_fee_bps, PerpError::InvalidParameter ); + // A position must open with more margin than it is liquidated at, or it + // could be liquidated in the same slot it opened. At most 100% of + // notional: more than that would demand collateral above the position's + // size. + require!( + parameters.initial_margin_bps > parameters.maintenance_margin_bps, + PerpError::InitialMarginNotAboveMaintenance + ); + require!( + parameters.initial_margin_bps <= denominator, + PerpError::InvalidParameter + ); // Zero would reject every real feed (which always reports some uncertainty); // above 100% is meaningless. Anything in between is a valid risk choice. require!( parameters.max_confidence_bps > 0 && parameters.max_confidence_bps < denominator, PerpError::InvalidParameter ); + // Zero would refuse every price move, however small. At 100% or more the + // band could never refuse a fall, since the oracle price is always + // positive. + require!( + parameters.max_price_deviation_bps > 0 && parameters.max_price_deviation_bps < denominator, + PerpError::InvalidPriceDeviation + ); + + // Seed the average with a validated oracle price, so the band is in force + // from the first trade. + let initial_price = read_oracle_price( + &context.accounts.oracle_feed, + parameters.oracle_scale, + parameters.max_confidence_bps, + )?; + let current_timestamp = Clock::get()?.unix_timestamp; let pool = &mut context.accounts.pool; pool.authority = *context.accounts.authority.address(); @@ -100,14 +134,18 @@ pub fn handle_initialize_pool( pool.long_size_scaled = 0; pool.short_size_scaled = 0; pool.cumulative_funding = 0; - pool.last_funding_timestamp = Clock::get()?.unix_timestamp; + pool.last_funding_timestamp = current_timestamp; + pool.average_price = initial_price; + pool.last_oracle_price = initial_price; + pool.average_price_timestamp = current_timestamp; pool.funding_rate_per_second = parameters.funding_rate_per_second; pool.open_fee_bps = parameters.open_fee_bps; pool.close_fee_bps = parameters.close_fee_bps; - pool.max_leverage = parameters.max_leverage; + pool.initial_margin_bps = parameters.initial_margin_bps; pool.maintenance_margin_bps = parameters.maintenance_margin_bps; pool.liquidation_fee_bps = parameters.liquidation_fee_bps; pool.max_confidence_bps = parameters.max_confidence_bps; + pool.max_price_deviation_bps = parameters.max_price_deviation_bps; pool.bump = context.bumps.pool; Ok(()) @@ -130,8 +168,9 @@ pub struct InitializePoolAccountConstraints { pub collateral_mint: Box>, /// CHECK: The oracle feed account. Its key is stored on the pool and every - /// read validates the layout, scale, and freshness; it is never trusted by - /// type. Swap for a real Pyth price feed in production. + /// read, including the one here that seeds the average price, validates + /// the layout, scale, and freshness; it is never trusted by type. Swap for + /// a real Pyth price feed in production. pub oracle_feed: UncheckedAccount, /// Liquidity-provider share mint. The pool account is its mint authority diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs index 88337e1df..33c621dee 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs @@ -6,6 +6,7 @@ pub mod liquidate_position; pub mod open_position; pub mod remove_liquidity; pub mod shared; +pub mod update_price_average; pub use add_liquidity::*; pub use close_position::*; @@ -14,3 +15,4 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; +pub use update_price_average::*; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs index e820f032f..8cf66d3ab 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs @@ -4,9 +4,11 @@ use anchor_spl::{ token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, }; -use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; +use crate::constants::{BASIS_POINTS_DENOMINATOR, POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, scale_size}; +use crate::instructions::shared::{ + basis_points_of, refresh_price_and_funding_within_band, scale_size, +}; use crate::state::{Pool, Position, Side}; pub fn handle_open_position( @@ -19,7 +21,7 @@ pub fn handle_open_position( require!(collateral_amount > 0 && size > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; // Slippage: a long must not fill above the caller's limit, a short not // below it. `0` opts out. @@ -32,21 +34,28 @@ pub fn handle_open_position( } // The open fee is taken out of the posted collateral; the rest backs the - // position. Leverage and margin are measured against this net collateral. + // position, and the initial margin is measured against this net collateral. let open_fee = basis_points_of(size, pool.open_fee_bps)?; let net_collateral = collateral_amount .checked_sub(open_fee) .ok_or(PerpError::InsufficientCollateral)?; require!(net_collateral > 0, PerpError::ZeroAmount); - let max_notional = (net_collateral as u128) - .checked_mul(pool.max_leverage as u128) + // Initial margin: net collateral must be at least `initial_margin_bps` of + // the notional size, compared as `net_collateral * 10_000 >= size * bps` + // so nothing is rounded. `initialize_pool` keeps the initial margin above + // the maintenance margin, so a position that passes this check opens with + // equity above the liquidation threshold. + let collateral_scaled = (net_collateral as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) .ok_or(PerpError::MathOverflow)?; - require!(size as u128 <= max_notional, PerpError::LeverageTooHigh); - - // Refuse a position that would open already inside the liquidation band. - let maintenance = basis_points_of(size, pool.maintenance_margin_bps)?; - require!(net_collateral > maintenance, PerpError::PositionNotHealthy); + let required_scaled = (size as u128) + .checked_mul(pool.initial_margin_bps as u128) + .ok_or(PerpError::MathOverflow)?; + require!( + collateral_scaled >= required_scaled, + PerpError::InitialMarginNotMet + ); // Reserve liquidity to cover this position's maximum recoverable profit // (its notional `size`). The reserve must be backed by liquidity-provider diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs index 9d3227ca1..c3cf66c56 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding}; +use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; use crate::state::Pool; pub fn handle_remove_liquidity( @@ -19,7 +19,7 @@ pub fn handle_remove_liquidity( require!(shares > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply(); let aum = liquidity_provider_aum(pool, price)?; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs index 4e47e3235..38d2ce17c 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs @@ -1,6 +1,8 @@ use anchor_lang::prelude::*; -use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; +use crate::constants::{ + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, PRICE_AVERAGE_WINDOW_SECONDS, SIZE_PRECISION, +}; use crate::errors::PerpError; use crate::state::{Pool, Position, Side}; @@ -216,16 +218,102 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { .map_err(|_| PerpError::MathOverflow.into()) } -/// The preamble every price-sensitive handler runs: read a validated oracle -/// price, then bring the pool's funding index up to the current time, so the -/// settlement that follows uses fresh numbers for both. Centralized so no -/// handler can settle a position against a stale funding index. +/// Fold the elapsed interval into the pool's `average_price`, then record +/// `price` as the latest observation. +/// +/// The interval since the last fold is credited to the price observed at +/// that fold, `last_oracle_price`, on the assumption that it held throughout: +/// +/// `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS` +/// +/// The price read now only starts counting from now, so it moves the average +/// only if it is still the oracle's price at a later read, weighted by the +/// seconds between the two reads; a read of a different price in between +/// replaces it. A pool left idle for a window or more therefore cannot have +/// its average set by one read. As with funding, a timestamp at or before the +/// stored one is treated as no time elapsed: the average and the stored stamp +/// stay where they are, and only `last_oracle_price` is updated. +pub fn fold_price_into_average(pool: &mut Pool, price: u64, current_timestamp: i64) -> Result<()> { + if current_timestamp <= pool.average_price_timestamp { + pool.last_oracle_price = price; + return Ok(()); + } + let elapsed = current_timestamp + .checked_sub(pool.average_price_timestamp) + .ok_or(PerpError::MathOverflow)?; + let weight = elapsed.min(PRICE_AVERAGE_WINDOW_SECONDS); + + let average = pool.average_price as i128; + // Multiply before dividing; the gap is signed, so the average moves down + // as readily as up. + let movement = (pool.last_oracle_price as i128) + .checked_sub(average) + .ok_or(PerpError::MathOverflow)? + .checked_mul(weight as i128) + .ok_or(PerpError::MathOverflow)? + .checked_div(PRICE_AVERAGE_WINDOW_SECONDS as i128) + .ok_or(PerpError::MathOverflow)?; + pool.average_price = average + .checked_add(movement) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + pool.last_oracle_price = price; + pool.average_price_timestamp = current_timestamp; + Ok(()) +} + +/// Refuse an oracle `price` more than `max_price_deviation_bps` away from the +/// pool's stored `average_price`: +/// `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. +pub fn require_price_within_band(pool: &Pool, price: u64) -> Result<()> { + let deviation_scaled = (price.abs_diff(pool.average_price) as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) + .ok_or(PerpError::MathOverflow)?; + let band_scaled = (pool.average_price as u128) + .checked_mul(pool.max_price_deviation_bps as u128) + .ok_or(PerpError::MathOverflow)?; + require!(deviation_scaled <= band_scaled, PerpError::PriceOutsideBand); + Ok(()) +} + +/// The preamble `liquidate_position` and `update_price_average` run: read a +/// validated oracle price, bring the pool's funding index up to the current +/// time, and fold the interval since the previous read into the pool's average +/// (see `fold_price_into_average`), so the settlement that follows uses fresh +/// numbers. Centralized so no handler can settle a position +/// against a stale funding index. +/// +/// No band check: liquidation has to keep working through a genuine price +/// move, because that is when positions go underwater, and +/// `update_price_average` is how the average catches up with one. pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountView) -> Result { - let price = crate::state::oracle::read_oracle_price( - oracle_feed, - pool.oracle_scale, - pool.max_confidence_bps, - )?; - accrue_funding(pool, Clock::get()?.unix_timestamp)?; + let price = read_pool_oracle_price(pool, oracle_feed)?; + apply_price_and_funding(pool, price)?; Ok(price) } + +/// The preamble for every handler that opens or closes a position or moves +/// liquidity: the same as `refresh_price_and_funding`, but first refuses a +/// price outside the band around the stored average, before anything is +/// folded in or the price is recorded. A single oracle print far from the +/// average therefore cannot open, close, deposit, or withdraw at that price. +pub fn refresh_price_and_funding_within_band( + pool: &mut Pool, + oracle_feed: &AccountView, +) -> Result { + let price = read_pool_oracle_price(pool, oracle_feed)?; + require_price_within_band(pool, price)?; + apply_price_and_funding(pool, price)?; + Ok(price) +} + +fn read_pool_oracle_price(pool: &Pool, oracle_feed: &AccountView) -> Result { + crate::state::oracle::read_oracle_price(oracle_feed, pool.oracle_scale, pool.max_confidence_bps) +} + +fn apply_price_and_funding(pool: &mut Pool, price: u64) -> Result<()> { + let current_timestamp = Clock::get()?.unix_timestamp; + accrue_funding(pool, current_timestamp)?; + fold_price_into_average(pool, price, current_timestamp) +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs new file mode 100644 index 000000000..9da9fa577 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs @@ -0,0 +1,30 @@ +use anchor_lang::prelude::*; + +use crate::constants::POOL_SEED; +use crate::instructions::shared::refresh_price_and_funding; +use crate::state::Pool; + +pub fn handle_update_price_average( + context: &mut Context, +) -> Result<()> { + refresh_price_and_funding(&mut context.accounts.pool, &context.accounts.oracle_feed)?; + Ok(()) +} + +#[derive(Accounts)] +pub struct UpdatePriceAverageAccountConstraints { + /// Anyone may update the average: the result depends only on the oracle + /// price and the clock, never on who calls. + pub caller: Signer, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + )] + pub pool: Box>, + + /// CHECK: validated by the `address = pool.oracle_feed` constraint below. + #[account(address = pool.oracle_feed)] + pub oracle_feed: UncheckedAccount, +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs index 6329dc855..723f03a81 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs @@ -1,10 +1,11 @@ use anchor_lang::prelude::*; mod constants; -mod errors; mod last_restart; // Public so the LiteSVM integration tests can build instruction arguments -// (`PoolParameters`, `Side`) against the program's own types. +// (`PoolParameters`, `Side`) against the program's own types, and match +// failures against `PerpError` codes. +pub mod errors; pub mod instructions; pub mod state; @@ -78,6 +79,18 @@ pub mod perpetual_futures { instructions::handle_liquidate_position(context) } + /// Read the oracle, credit the seconds since the previous read to the + /// price that read saw, record the current price for the next read, and + /// accrue funding up to now. Permissionless: after a genuine price move + /// takes the oracle outside the pool's band, anyone can call this + /// repeatedly as time passes to walk the average toward the new price until + /// trading resumes. + pub fn update_price_average( + context: &mut Context, + ) -> Result<()> { + instructions::handle_update_price_average(context) + } + /// The pool operator sweeps the accumulated program fees from the vault. pub fn collect_fees(context: &mut Context) -> Result<()> { instructions::handle_collect_fees(context) diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs index a8df9b5c0..ad7993d5a 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs @@ -71,6 +71,23 @@ pub struct Pool { /// the cluster's slot time. pub last_funding_timestamp: i64, + /// Time-weighted moving average of the oracle price, in the pool's + /// `oracle_scale` fixed point. Seeded with the oracle price when the pool is + /// created. Every handler that reads the oracle credits the seconds since + /// the previous read to `last_oracle_price`, the price that read saw. + /// Trading and liquidity handlers refuse an oracle price more than + /// `max_price_deviation_bps` away from it, so a sudden jump pauses them + /// until the average catches up. + pub average_price: u64, + + /// The oracle price at the most recent read, in `oracle_scale` fixed point. + /// The next read folds it into `average_price` for the seconds in between. + pub last_oracle_price: u64, + + /// The Clock's `unix_timestamp` of the most recent fold into + /// `average_price`. + pub average_price_timestamp: i64, + /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. pub funding_rate_per_second: u64, @@ -80,11 +97,13 @@ pub struct Pool { pub close_fee_bps: u16, - /// Highest leverage a position may open at (`size <= collateral * max`). - pub max_leverage: u16, + /// Net collateral a position must post to open, in basis points of its + /// notional size: 1_000 allows at most 10x leverage. Always above + /// `maintenance_margin_bps`, so no position opens already liquidatable. + pub initial_margin_bps: u16, - /// Equity threshold, in basis points of notional, below which a position is - /// liquidatable. + /// Equity threshold, in basis points of notional, at or below which a + /// position is liquidatable. pub maintenance_margin_bps: u16, /// Reward paid to a liquidator, in basis points of the liquidated notional. @@ -94,6 +113,10 @@ pub struct Pool { /// pool will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, + /// Widest gap the pool trades across between the oracle price and + /// `average_price`, in basis points of `average_price`. + pub max_price_deviation_bps: u16, + /// Bump of this account's own address. The pool owns the custody vault /// and is the LP mint's authority, so it signs vault transfers and /// mint/burn CPIs with `[POOL_SEED, collateral_mint, oracle_feed, bump]`; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs index 9cfc05847..791fbb1bd 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -4,7 +4,11 @@ use { InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, - perpetual_futures::{instructions::initialize_pool::PoolParameters, state::Pool, state::Side}, + perpetual_futures::{ + errors::PerpError, + instructions::initialize_pool::PoolParameters, + state::{Pool, Position, Side}, + }, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, get_token_account_balance, mint_tokens_to_token_account, @@ -15,6 +19,9 @@ use { // Matches `MAX_FUNDING_RATE_PER_SECOND` in the program's constants: the // steepest funding rate `initialize_pool` accepts. const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; +// Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds +// replaces the pool's average price with the oracle price. +const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; // Ten years, in seconds. const TEN_YEARS: i64 = 315_360_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 @@ -50,6 +57,37 @@ fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } +/// The parameters every test market uses unless a test overrides one: 0.1% +/// open and close fees, a 10% initial margin (10x leverage), a 5% maintenance +/// margin, a 1% liquidation fee, a 1% maximum confidence band, and a 20% price +/// band around the pool's average price. +fn default_parameters(funding_rate_per_second: u64) -> PoolParameters { + PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + initial_margin_bps: 1_000, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + max_price_deviation_bps: 2_000, + } +} + +/// Assert that `result` failed with the program's `expected` error. Anchor +/// reports a program error as `Custom(6000 + the variant's index)`. +fn assert_fails_with(result: Result, expected: PerpError) { + let code = expected as u32 + 6000; + let Err(error) = result else { + panic!("the transaction should have failed with error code {code}"); + }; + assert!( + error.contains(&format!("Custom({code})")), + "expected error code {code}, got: {error}" + ); +} + /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -67,23 +105,14 @@ impl Market { /// funding rate. The admin is both the pool operator and the oracle feed /// authority. fn new(initial_price: i128, funding_rate_per_second: u64) -> Market { - let parameters = PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - open_fee_bps: 10, - close_fee_bps: 10, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, - }; - Market::try_new(initial_price, parameters).expect("pool initialization should succeed") + Market::try_new(initial_price, default_parameters(funding_rate_per_second)) + .expect("pool initialization should succeed") } /// Like `new`, but takes the full parameter set and surfaces an /// `initialize_pool` rejection instead of panicking, so tests can probe the /// parameter validation. - fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { + fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { let mut svm = anchor_v2_testing::svm(); svm.add_program( perpetual_futures::id(), @@ -165,7 +194,7 @@ impl Market { &[&admin], &admin.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; Ok(Market { svm, @@ -273,7 +302,7 @@ impl Market { provider_collateral: Address, amount: u64, minimum_shares_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -303,8 +332,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn remove_liquidity( @@ -313,7 +341,7 @@ impl Market { provider_collateral: Address, shares: u64, minimum_amount_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -343,8 +371,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn position_pda(&self, owner: &Address, side: Side) -> Address { @@ -367,7 +394,7 @@ impl Market { collateral_amount: u64, size: u64, acceptable_price: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -398,8 +425,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn close_position( @@ -408,7 +434,7 @@ impl Market { trader_collateral: Address, side: Side, minimum_payout: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -433,8 +459,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn liquidate( @@ -443,7 +468,7 @@ impl Market { owner: &Address, owner_collateral: Address, side: Side, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(owner, side); let liquidator_collateral = derive_ata(&liquidator.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( @@ -471,11 +496,10 @@ impl Market { &[liquidator], &liquidator.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } - fn collect_fees(&mut self, authority: &Keypair) -> Result<(), ()> { + fn collect_fees(&mut self, authority: &Keypair) -> Result<(), String> { let authority_collateral = derive_ata(&authority.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -498,8 +522,40 @@ impl Market { &[authority], &authority.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) + } + + fn update_price_average(&mut self, caller: &Keypair) -> Result<(), String> { + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::UpdatePriceAverage {}.data(), + perpetual_futures::accounts::UpdatePriceAverageAccountConstraints { + caller: caller.pubkey(), + pool: self.pool, + oracle_feed: self.feed, + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[caller], + &caller.pubkey(), + ) + .map_err(|error| format!("{error:?}")) + } + + /// Hold the oracle at `price` while the pool's average catches up with + /// it: one update records `price` as the latest observation, then a full + /// averaging window passes with the price republished so it is fresh, and + /// a second update credits that window to `price`. A price more than the + /// band away from the average cannot be traded at until this has run. + fn settle_average_at(&mut self, price: i128) { + let caller = self.payer.insecure_clone(); + self.update_price_average(&caller).unwrap(); + self.pass_seconds(PRICE_AVERAGE_WINDOW_SECONDS); + self.set_price(price); + self.update_price_average(&caller).unwrap(); } /// Deposit a large amount of liquidity so the pool can pay trader profits, @@ -521,7 +577,17 @@ fn test_initialize_pool() { assert_eq!(pool.collateral_mint, market.collateral_mint); assert_eq!(pool.oracle_feed, market.feed); assert_eq!(pool.oracle_scale, ORACLE_SCALE); - assert_eq!(pool.max_leverage, 10); + assert_eq!(pool.initial_margin_bps, 1_000); + assert_eq!(pool.max_price_deviation_bps, 2_000); + // The average starts at the oracle price the pool was created against. + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!( + pool.average_price_timestamp, + market + .svm + .get_sysvar::() + .unix_timestamp + ); assert_eq!(pool.liquidity, 0); assert_eq!(pool.total_collateral, 0); @@ -847,17 +913,53 @@ fn test_open_rejects_zero_amounts() { } #[test] -fn test_open_rejects_excess_leverage() { +fn test_open_rejects_position_below_initial_margin() { let mut market = Market::default_market(); market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); + let (trader, trader_collateral) = market.funded_trader(2_000 * ONE_USDC); - // max_leverage is 10x; 11x must be rejected. - let size = 11_000 * ONE_USDC; - assert!(market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .is_err()); + // The initial margin is 10% of notional. 1,000 USDC of collateral less + // the 11 USDC open fee leaves 989 USDC, short of the 1,100 USDC an 11,000 + // USDC position needs. + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + 1_000 * ONE_USDC, + 11_000 * ONE_USDC, + 0, + ), + PerpError::InitialMarginNotMet, + ); + + // A 10,000 USDC position needs 1,000 USDC net of its 10 USDC open fee. + // One minor unit short of 1,010 USDC is refused, and exactly 1,010 USDC + // opens at 10x. + let size = 10_000 * ONE_USDC; + let exact_collateral = 1_010 * ONE_USDC; + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + exact_collateral - 1, + size, + 0, + ), + PerpError::InitialMarginNotMet, + ); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + exact_collateral, + size, + 0, + ) + .unwrap(); + assert_eq!(market.pool_state().total_collateral, size / 10); } #[test] @@ -1056,18 +1158,18 @@ fn test_funding_follows_seconds_not_slots() { #[test] fn test_initialize_pool_rejects_funding_rate_above_the_maximum() { // The rate is fixed at creation, so this is the only place it is checked. - let parameters = |funding_rate_per_second| PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - open_fee_bps: 10, - close_fee_bps: 10, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, - }; - assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND + 1)).is_err()); - assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND)).is_ok()); + assert_fails_with( + Market::try_new( + dollars(100), + default_parameters(MAX_FUNDING_RATE_PER_SECOND + 1), + ), + PerpError::InvalidParameter, + ); + assert!(Market::try_new( + dollars(100), + default_parameters(MAX_FUNDING_RATE_PER_SECOND) + ) + .is_ok()); } /// The pool operator trading against their own pool. The lighter side of open @@ -1295,8 +1397,11 @@ fn test_profit_capped_at_reserved_notional() { .unwrap(); // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). + // profit is capped at the reserved notional (`size`). A move this large is + // far outside the price band, so the average has to catch up before the + // position can close. market.set_price(dollars(300)); + market.settle_average_at(dollars(300)); market .close_position(&trader, trader_collateral, Side::Long, 0) .unwrap(); @@ -1345,14 +1450,304 @@ fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. let parameters = PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second: 0, - open_fee_bps: 10, close_fee_bps: 600, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, + ..default_parameters(0) + }; + assert_fails_with( + Market::try_new(dollars(100), parameters), + PerpError::InvalidParameter, + ); +} + +#[test] +fn test_initialize_pool_rejects_initial_margin_at_or_below_maintenance() { + // An initial margin at or below the 5% maintenance margin would let a + // position open already liquidatable. + let with_initial_margin = |initial_margin_bps| PoolParameters { + initial_margin_bps, + ..default_parameters(0) + }; + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(500)), + PerpError::InitialMarginNotAboveMaintenance, + ); + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(350)), + PerpError::InitialMarginNotAboveMaintenance, + ); + + // Above 100% of notional is refused too. One basis point above the + // maintenance margin, and exactly 100%, are accepted. + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(10_001)), + PerpError::InvalidParameter, + ); + assert!(Market::try_new(dollars(100), with_initial_margin(501)).is_ok()); + assert!(Market::try_new(dollars(100), with_initial_margin(10_000)).is_ok()); +} + +#[test] +fn test_initialize_pool_rejects_price_deviation_outside_range() { + let with_deviation = |max_price_deviation_bps| PoolParameters { + max_price_deviation_bps, + ..default_parameters(0) }; - assert!(Market::try_new(dollars(100), parameters).is_err()); + for rejected in [0, 10_000] { + assert_fails_with( + Market::try_new(dollars(100), with_deviation(rejected)), + PerpError::InvalidPriceDeviation, + ); + } + assert!(Market::try_new(dollars(100), with_deviation(1)).is_ok()); + assert!(Market::try_new(dollars(100), with_deviation(9_999)).is_ok()); +} + +/// A single oracle print far from the pool's average cannot be traded at: the +/// open is refused before the price is folded into the average. +#[test] +fn test_open_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + + // The band is 20% around the $100 average: $125 and $79 are outside it. + for outside_price in [dollars(125), dollars(79)] { + market.set_price(outside_price); + // The two refused opens are otherwise byte-identical transactions. + market.svm.expire_blockhash(); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); + // The refused open folded nothing into the average. + assert_eq!(market.pool_state().average_price, dollars(100) as u64); + } + + // $118 is inside the band, and opens at that price. + market.set_price(dollars(118)); + market.svm.expire_blockhash(); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + let position_account = market + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .unwrap(); + let position = Position::try_deserialize(&mut position_account.data.as_slice()).unwrap(); + assert_eq!(position.entry_price, dollars(118) as u64); +} + +#[test] +fn test_close_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + // A jump to $125 would pay the long $1,250, but $125 is 25% from the + // $100 average, outside the 20% band. + market.set_price(dollars(125)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::PriceOutsideBand, + ); + + // At $115, inside the band, the close goes through and pays the 15% gain. + market.set_price(dollars(115)); + market.svm.expire_blockhash(); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + let fee = size / 1_000; + let profit = size * 15 / 100; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee + profit - fee + ); +} + +/// Liquidation has no band check: a genuine crash is when positions go +/// underwater, so the pool has to be able to liquidate through one. +#[test] +fn test_liquidation_runs_outside_band() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_100 * ONE_USDC; + let size = 10_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + // $75 is 25% below the $100 average, so the owner cannot close there. + market.set_price(dollars(75)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::PriceOutsideBand, + ); + + let liquidator = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + assert!(market + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .is_none()); + assert_eq!(market.pool_state().long_size, 0); +} + +#[test] +fn test_liquidity_changes_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + let (provider, provider_collateral) = market.seed_liquidity(10_000 * ONE_USDC); + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + + // $76 is 24% below the $100 average. + market.set_price(dollars(76)); + let (depositor, depositor_collateral) = market.funded_trader(5_000 * ONE_USDC); + assert_fails_with( + market.add_liquidity(&depositor, depositor_collateral, 5_000 * ONE_USDC, 0), + PerpError::PriceOutsideBand, + ); + assert_fails_with( + market.remove_liquidity(&provider, provider_collateral, shares, 0), + PerpError::PriceOutsideBand, + ); +} + +/// After a genuine move outside the band, anyone can walk the average toward +/// the new price with `update_price_average`, and trading resumes once the +/// price is back inside the band. +#[test] +fn test_price_average_catches_up_after_genuine_move() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + + // NVDAx reprices from $100 to $130, 30% away from the average. + let new_price = dollars(130); + market.set_price(new_price); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); + + // Every two minutes the keeper calls `update_price_average`. Each call + // credits the two minutes since the previous read to the price that read + // saw, a fifth of the window. The first call credits $100, the price + // before the move, and records $130; each later call moves the average a + // fifth of the remaining gap to $130: $100, then $106, then $110.80. $130 + // is within 20% of any average from $108.34 up, so the third update + // reopens trading. + let mut updates = 0; + loop { + market.pass_seconds(120); + market.set_price(new_price); + market.update_price_average(&keeper).unwrap(); + updates += 1; + let opened = + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0); + if opened.is_ok() { + break; + } + assert_fails_with(opened, PerpError::PriceOutsideBand); + assert!(updates < 10, "the average never caught up"); + } + assert_eq!(updates, 3); + let pool = market.pool_state(); + assert_eq!(pool.average_price, 11_080_000_000); + assert_eq!(pool.last_oracle_price, new_price as u64); +} + +#[test] +fn test_single_update_moves_average_by_elapsed_fraction() { + let mut market = Market::default_market(); + let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + let created_at = market.pool_state().average_price_timestamp; + + // The first update after the oracle moves to $115 credits the four + // minutes since creation to $100, the price seen at creation, so the + // average stays at $100 and $115 is recorded for the next read. + market.pass_seconds(240); + market.set_price(dollars(115)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!(pool.last_oracle_price, dollars(115) as u64); + assert_eq!(pool.average_price_timestamp, created_at + 240); + + // Four more minutes at $115 are 240 of the 600-second window, so the next + // update moves the average 240/600 of the way from $100 to $115: to $106. + market.pass_seconds(240); + market.set_price(dollars(115)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(106) as u64); + assert_eq!(pool.average_price_timestamp, created_at + 480); + + // Fifteen minutes is more than a full window, so the next update replaces + // the average with $115, the price at the previous read, and records the + // fall to $97. One more update credits $97 for a full window. + market.pass_seconds(900); + market.set_price(dollars(97)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(115) as u64); + assert_eq!(pool.last_oracle_price, dollars(97) as u64); + market.pass_seconds(900); + market.set_price(dollars(97)); + market.update_price_average(&keeper).unwrap(); + assert_eq!(market.pool_state().average_price, dollars(97) as u64); +} + +/// A pool left idle for more than a window cannot have its average set by one +/// read of a manipulated price. The read only records the price; the interval +/// before it is credited to the price seen at the read before. Once a read of +/// the real price replaces it, the manipulated price has moved the average +/// only by the seconds between the two reads. +#[test] +fn test_one_manipulated_read_after_idle_does_not_move_average() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + let attacker = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + + // Fifteen idle minutes, then the oracle is pushed to $160 and the + // attacker calls `update_price_average`. The average stays at $100. + market.pass_seconds(900); + market.set_price(dollars(160)); + market.update_price_average(&attacker).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!(pool.last_oracle_price, dollars(160) as u64); + + // Six seconds later the oracle is back at $100 and is read again. The six + // seconds are credited to $160: the average moves 6/600 of the $60 gap, + // to $100.60, and $100 replaces $160 as the latest observation. + market.pass_seconds(6); + market.set_price(dollars(100)); + market.update_price_average(&attacker).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, 10_060_000_000); + assert_eq!(pool.last_oracle_price, dollars(100) as u64); + + // An open at $160 is still refused. + market.set_price(dollars(160)); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); } diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 47453e0df..dbef03779 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,65 @@ # Changelog +## 2026-10-01 + +Replace the leverage cap with an initial margin. `initialize_pool`'s +`max_leverage` argument and `Pool::max_leverage` are now `initial_margin_bps`, +the net collateral a position must post to open, in basis points of its size +(1,000 is 10x). `initialize_pool` requires `maintenance_margin_bps < +initial_margin_bps <= 10_000`, refusing an initial margin at or below the +maintenance margin with the new `INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE` (19) and +one above 10,000 with `INVALID_PARAMETER`; `MAX_LEVERAGE_CEILING` is removed. +`open_position` checks `net_collateral * 10_000 >= size * initial_margin_bps` +and fails with `INITIAL_MARGIN_NOT_MET`, which takes `LEVERAGE_TOO_HIGH`'s code +(2). Its separate check that a new position starts above the maintenance margin +is removed, because the initial margin implies it; `POSITION_NOT_HEALTHY` +remains for `close_position`. An open fee larger than the posted collateral now +fails with `INSUFFICIENT_COLLATERAL` (17), as in the Anchor version, rather than +`INSUFFICIENT_LIQUIDITY`. + +Add a price band around a program-maintained average price. A fresh, confident +oracle print could still be wrong, and every handler traded at it. The pool now +keeps `average_price`, a time-weighted moving average of the oracle price, +`last_oracle_price`, the price at the most recent oracle read, and +`average_price_timestamp`. `initialize_pool` seeds the average and +`last_oracle_price` from the oracle. Every handler that reads the oracle credits +the seconds since the previous read to the price that read saw, +`average += (last_oracle_price - average) * min(elapsed, +PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new +constant at 600 seconds, and then records the price it read as +`last_oracle_price`. The price read now only counts from now, so a pool left +idle for a window or more cannot have its average set by one read of a +manipulated price: that price moves the average only if the oracle still shows +it at a later read, weighted by the seconds between the two reads. +`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse +a price outside `|price - average_price| * 10_000 <= average_price * +max_price_deviation_bps` with the new `PRICE_OUTSIDE_BAND` (21), checked against +the stored average before anything is folded in. `liquidate_position` folds and +records without the check. The new permissionless `update_price_average` +handler (discriminator 7) folds and records too, also without the check, so +keepers calling it repeatedly as time passes can walk the average to a genuine +move. `max_price_deviation_bps` is a +new `initialize_pool` argument, which must be above zero and below 10,000, or +the handler fails with the new `INVALID_PRICE_DEVIATION` (20). `shared.rs` has +`refresh_price_and_funding_within_band` for the four band-checked handlers +beside `refresh_price_and_funding` for the other two. + +Tested by `open_rejects_position_below_initial_margin` (formerly +`open_rejects_excess_leverage`, now checking both sides of the boundary), +`initialize_pool_records_the_margins_band_and_average`, +`initialize_pool_rejects_initial_margin_at_or_below_maintenance`, +`initialize_pool_rejects_price_deviation_outside_range`, +`open_rejected_when_oracle_jumps_outside_band`, +`close_rejected_when_oracle_jumps_outside_band`, +`liquidity_changes_rejected_when_oracle_jumps_outside_band`, +`liquidation_runs_outside_band`, `price_average_catches_up_after_genuine_move`, +`single_update_moves_average_by_elapsed_fraction` and +`one_manipulated_read_after_idle_does_not_move_average`. The default test pool +uses a 1,000 basis point initial margin and a 2,000 basis point band; +`profit_is_capped_at_the_reserved_notional` triples the price, far outside the +band, so it now calls `update_price_average` to record the new price, lets a +full window pass, and calls it again before closing. + ## 2026-09-30 Remove `set_funding_rate` (discriminator 7). The pool's authority could change diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index 4d533d4d6..6638b370a 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -30,10 +30,32 @@ math. This page only covers what differs in the Quasar version. Tests run in-process with [`quasar-svm`](https://github.com/blueshift-gg/quasar-svm). They build the program, set up a collateral mint, oracle feed, and funded -wallets, then exercise pool initialization, liquidity add/remove, opening and -closing a long in profit, leverage rejection, the funding-rate maximum, an -operator's wallet on the lighter side earning only the fixed rate, funding that -follows seconds rather than slots, liquidation, and fee collection. +wallets, then exercise: + +- pool initialization, including its checks on the initial margin (above the + maintenance margin, at most 10,000 basis points) and the price band (above + zero, below 10,000 basis points) +- liquidity add/remove, and share inflation through a provider's own trades +- opening and closing a long in profit, and the initial margin on both sides + of its boundary +- stale-price, pre-restart-price, and wide-confidence rejection +- the funding-rate maximum, an operator's wallet on the lighter side earning + only the fixed rate, and funding that follows seconds rather than slots +- the price band: opens, closes, deposits and withdrawals refused when the + oracle jumps outside it, liquidation running outside it, the exact average + after one `update_price_average` + (`single_update_moves_average_by_elapsed_fraction`), and repeated updates + walking the average to a genuine move until trading resumes + (`price_average_catches_up_after_genuine_move`), and one manipulated read + after an idle window leaving the average where it was + (`one_manipulated_read_after_idle_does_not_move_average`) +- liquidation, reserved liquidity, and fee collection + +Program errors are `ProgramError::Custom` codes listed in +`instructions/shared.rs`, with the same names as the Anchor version's +`PerpError` variants in upper snake case: `INITIAL_MARGIN_NOT_MET` (2), +`INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE` (19), `INVALID_PRICE_DEVIATION` (20) and +`PRICE_OUTSIDE_BAND` (21) among them. `update_price_average` is discriminator 7. ```bash cargo build-sbf diff --git a/finance/perpetual-futures/quasar/src/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs index c0ff398db..3bb38298b 100644 --- a/finance/perpetual-futures/quasar/src/constants.rs +++ b/finance/perpetual-futures/quasar/src/constants.rs @@ -21,8 +21,17 @@ pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// the cluster's slot time, which the protocol lowers over time. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; -/// Upper bound on a pool's configurable `max_leverage`. -pub const MAX_LEVERAGE_CEILING: u16 = 100; +/// How many seconds of oracle prices the pool's `average_price` follows. Each +/// fold moves the average toward the price seen at the previous read by +/// `elapsed / window` of the gap between them, and an interval of a full window +/// or more replaces the average with that price. Ten minutes is long enough +/// that a price seen at two reads six seconds apart, about as long as a faulty +/// or manipulated oracle print lasts, moves the average by one percent of its +/// jump, and short enough that a genuine move is back inside the band within +/// minutes of repeated reads. Counted on the Clock's `unix_timestamp`, +/// like funding: it is a span of wall-clock time, and the second or two of +/// leader drift changes a fold's weight by well under one percent. +pub const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; /// Upper bound on a pool's `funding_rate_per_second`, in `FUNDING_PRECISION` /// units: 277 billionths of a position's size per second, just under 0.1% of diff --git a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs index 0f0b1daab..148d44d18 100644 --- a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs @@ -1,7 +1,9 @@ use { crate::{ constants::MINIMUM_LIQUIDITY, - instructions::shared::{err, error, refresh_price_and_funding, traders_unrealized_pnl}, + instructions::shared::{ + err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, + }, state::Pool, LpMintPda, }, @@ -54,7 +56,7 @@ pub fn handle_add_liquidity( let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding( + let price = refresh_price_and_funding_within_band( &mut accounts.pool, &accounts.oracle_feed, slot, diff --git a/finance/perpetual-futures/quasar/src/instructions/close_position.rs b/finance/perpetual-futures/quasar/src/instructions/close_position.rs index 006513e16..07024da61 100644 --- a/finance/perpetual-futures/quasar/src/instructions/close_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/close_position.rs @@ -2,7 +2,8 @@ use { crate::{ constants::SIDE_LONG, instructions::shared::{ - basis_points_of, err, error, position_funding, position_pnl, refresh_price_and_funding, + basis_points_of, err, error, position_funding, position_pnl, + refresh_price_and_funding_within_band, }, state::{Pool, Position}, }, @@ -48,7 +49,7 @@ pub fn handle_close_position( ) -> Result<(), ProgramError> { let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding( + let price = refresh_price_and_funding_within_band( &mut accounts.pool, &accounts.oracle_feed, slot, diff --git a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs index 6e0f57355..ca466763e 100644 --- a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs @@ -1,7 +1,7 @@ use { crate::{ - constants::{BASIS_POINTS_DENOMINATOR, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING}, - instructions::shared::{err, error}, + constants::{BASIS_POINTS_DENOMINATOR, MAX_FUNDING_RATE_PER_SECOND}, + instructions::shared::{err, error, read_feed_price}, state::{Pool, PoolInner}, LpMintPda, VaultPda, }, @@ -21,7 +21,8 @@ pub struct InitializePool { )] pub pool: Account, pub collateral_mint: Account, - /// CHECK: stored on the pool; every read validates layout, scale, freshness. + /// CHECK: stored on the pool; every read, including the one here that seeds + /// the average price, validates layout, scale, freshness. pub oracle_feed: UncheckedAccount, /// Liquidity-provider share mint; the pool account is its mint authority. #[account( @@ -55,10 +56,11 @@ pub fn handle_initialize_pool( funding_rate_per_second: u64, open_fee_bps: u16, close_fee_bps: u16, - max_leverage: u16, + initial_margin_bps: u16, maintenance_margin_bps: u16, liquidation_fee_bps: u16, max_confidence_bps: u16, + max_price_deviation_bps: u16, bumps: &InitializePoolBumps, ) -> Result<(), ProgramError> { let denominator = BASIS_POINTS_DENOMINATOR as u16; @@ -67,9 +69,6 @@ pub fn handle_initialize_pool( if funding_rate_per_second > MAX_FUNDING_RATE_PER_SECOND { return Err(err(error::INVALID_PARAMETER)); } - if !(1..=MAX_LEVERAGE_CEILING).contains(&max_leverage) { - return Err(err(error::INVALID_PARAMETER)); - } if open_fee_bps >= denominator || close_fee_bps >= denominator || liquidation_fee_bps >= denominator @@ -87,10 +86,34 @@ pub fn handle_initialize_pool( if maintenance_margin_bps <= close_fee_bps { return Err(err(error::INVALID_PARAMETER)); } + // A position must open with more margin than it is liquidated at, or it + // could be liquidated in the same slot it opened. At most 100% of + // notional: more than that would demand collateral above the position's + // size. + if initial_margin_bps <= maintenance_margin_bps { + return Err(err(error::INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE)); + } + if initial_margin_bps > denominator { + return Err(err(error::INVALID_PARAMETER)); + } if max_confidence_bps == 0 || max_confidence_bps >= denominator { return Err(err(error::INVALID_PARAMETER)); } + // Zero would refuse every price move, however small. At 100% or more the + // band could never refuse a fall, since the oracle price is always + // positive. + if max_price_deviation_bps == 0 || max_price_deviation_bps >= denominator { + return Err(err(error::INVALID_PRICE_DEVIATION)); + } + // Seed the average with a validated oracle price, so the band is in force + // from the first trade. + let initial_price = read_feed_price( + &accounts.oracle_feed, + oracle_scale, + accounts.clock.slot.get(), + max_confidence_bps, + )?; let unix_timestamp = accounts.clock.unix_timestamp.get(); accounts.pool.set_inner(PoolInner { authority: *accounts.authority.address(), @@ -109,13 +132,17 @@ pub fn handle_initialize_pool( short_size_scaled: 0, cumulative_funding: 0, last_funding_timestamp: unix_timestamp, + average_price: initial_price, + last_oracle_price: initial_price, + average_price_timestamp: unix_timestamp, funding_rate_per_second, open_fee_bps, close_fee_bps, - max_leverage, + initial_margin_bps, maintenance_margin_bps, liquidation_fee_bps, max_confidence_bps, + max_price_deviation_bps, bump: bumps.pool, }); Ok(()) diff --git a/finance/perpetual-futures/quasar/src/instructions/mod.rs b/finance/perpetual-futures/quasar/src/instructions/mod.rs index 00453e621..7026141fc 100644 --- a/finance/perpetual-futures/quasar/src/instructions/mod.rs +++ b/finance/perpetual-futures/quasar/src/instructions/mod.rs @@ -6,6 +6,7 @@ mod liquidate_position; mod open_position; mod remove_liquidity; pub mod shared; +mod update_price_average; pub use add_liquidity::*; pub use close_position::*; @@ -14,3 +15,4 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; +pub use update_price_average::*; diff --git a/finance/perpetual-futures/quasar/src/instructions/open_position.rs b/finance/perpetual-futures/quasar/src/instructions/open_position.rs index 61d0d557e..eaf87781f 100644 --- a/finance/perpetual-futures/quasar/src/instructions/open_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/open_position.rs @@ -1,8 +1,8 @@ use { crate::{ - constants::{SIDE_LONG, SIDE_SHORT}, + constants::{BASIS_POINTS_DENOMINATOR, SIDE_LONG, SIDE_SHORT}, instructions::shared::{ - basis_points_of, err, error, refresh_price_and_funding, scale_size, + basis_points_of, err, error, refresh_price_and_funding_within_band, scale_size, }, state::{Pool, Position, PositionInner}, }, @@ -58,7 +58,7 @@ pub fn handle_open_position( let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding( + let price = refresh_price_and_funding_within_band( &mut accounts.pool, &accounts.oracle_feed, slot, @@ -76,24 +76,29 @@ pub fn handle_open_position( } } + // The open fee is taken out of the posted collateral; the rest backs the + // position, and the initial margin is measured against this net collateral. let open_fee = basis_points_of(size, accounts.pool.open_fee_bps.get())?; let net_collateral = collateral_amount .checked_sub(open_fee) - .ok_or_else(|| err(error::INSUFFICIENT_LIQUIDITY))?; + .ok_or_else(|| err(error::INSUFFICIENT_COLLATERAL))?; if net_collateral == 0 { return Err(err(error::ZERO_AMOUNT)); } - let max_notional = (net_collateral as u128) - .checked_mul(accounts.pool.max_leverage.get() as u128) + // Initial margin: net collateral must be at least `initial_margin_bps` of + // the notional size, compared as `net_collateral * 10_000 >= size * bps` + // so nothing is rounded. `initialize_pool` keeps the initial margin above + // the maintenance margin, so a position that passes this check opens with + // equity above the liquidation threshold. + let collateral_scaled = (net_collateral as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) .ok_or(ProgramError::ArithmeticOverflow)?; - if size as u128 > max_notional { - return Err(err(error::LEVERAGE_TOO_HIGH)); - } - - let maintenance = basis_points_of(size, accounts.pool.maintenance_margin_bps.get())?; - if net_collateral <= maintenance { - return Err(err(error::POSITION_NOT_HEALTHY)); + let required_scaled = (size as u128) + .checked_mul(accounts.pool.initial_margin_bps.get() as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + if collateral_scaled < required_scaled { + return Err(err(error::INITIAL_MARGIN_NOT_MET)); } // Reserve liquidity to cover this position's maximum recoverable profit diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs index 20ce56e4d..5eeff7afd 100644 --- a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs @@ -1,7 +1,9 @@ use { crate::{ constants::MINIMUM_LIQUIDITY, - instructions::shared::{err, error, refresh_price_and_funding, traders_unrealized_pnl}, + instructions::shared::{ + err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, + }, state::Pool, LpMintPda, }, @@ -54,7 +56,7 @@ pub fn handle_remove_liquidity( let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding( + let price = refresh_price_and_funding_within_band( &mut accounts.pool, &accounts.oracle_feed, slot, diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index f4a08c1ac..4dca8c2bb 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -7,14 +7,14 @@ use quasar_lang::{prelude::*, sysvars::Sysvar}; use crate::last_restart::LastRestartSlot; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, MAX_PRICE_STALENESS_SLOTS, SIDE_LONG, - SIZE_PRECISION, + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, MAX_PRICE_STALENESS_SLOTS, + PRICE_AVERAGE_WINDOW_SECONDS, SIDE_LONG, SIZE_PRECISION, }; use crate::state::Pool; pub mod error { pub const ZERO_AMOUNT: u32 = 0; - pub const LEVERAGE_TOO_HIGH: u32 = 2; + pub const INITIAL_MARGIN_NOT_MET: u32 = 2; pub const INVALID_PARAMETER: u32 = 3; pub const STALE_PRICE: u32 = 4; pub const NON_POSITIVE_PRICE: u32 = 5; @@ -31,6 +31,9 @@ pub mod error { pub const ORACLE_CONFIDENCE_TOO_WIDE: u32 = 16; pub const INSUFFICIENT_COLLATERAL: u32 = 17; pub const PRICE_PREDATES_RESTART: u32 = 18; + pub const INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE: u32 = 19; + pub const INVALID_PRICE_DEVIATION: u32 = 20; + pub const PRICE_OUTSIDE_BAND: u32 = 21; } #[inline(always)] @@ -267,30 +270,136 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result, + price: u64, + current_timestamp: i64, +) -> Result<(), ProgramError> { + let average_price_timestamp = pool.average_price_timestamp.get(); + if current_timestamp <= average_price_timestamp { + pool.last_oracle_price.set(price); + return Ok(()); + } + let elapsed = current_timestamp + .checked_sub(average_price_timestamp) + .ok_or_else(overflow)?; + let weight = elapsed.min(PRICE_AVERAGE_WINDOW_SECONDS); + + let average = pool.average_price.get() as i128; + // Multiply before dividing; the gap is signed, so the average moves down + // as readily as up. + let movement = (pool.last_oracle_price.get() as i128) + .checked_sub(average) + .ok_or_else(overflow)? + .checked_mul(weight as i128) + .ok_or_else(overflow)? + .checked_div(PRICE_AVERAGE_WINDOW_SECONDS as i128) + .ok_or_else(overflow)?; + let new_average = average.checked_add(movement).ok_or_else(overflow)?; + pool.average_price + .set(u64::try_from(new_average).map_err(|_| overflow())?); + pool.last_oracle_price.set(price); + pool.average_price_timestamp.set(current_timestamp); + Ok(()) +} + +/// Refuse an oracle `price` more than `max_price_deviation_bps` away from the +/// pool's stored `average_price`: +/// `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. +pub fn require_price_within_band(pool: &Account, price: u64) -> Result<(), ProgramError> { + let average_price = pool.average_price.get(); + let deviation_scaled = (price.abs_diff(average_price) as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) + .ok_or_else(overflow)?; + let band_scaled = (average_price as u128) + .checked_mul(pool.max_price_deviation_bps.get() as u128) + .ok_or_else(overflow)?; + if deviation_scaled > band_scaled { + return Err(err(error::PRICE_OUTSIDE_BAND)); + } + Ok(()) +} + +/// Read and validate the oracle price from the feed account, checked for +/// freshness against `slot`. +pub fn read_feed_price( + oracle_feed: &UncheckedAccount, + expected_scale: u32, + slot: u64, + max_confidence_bps: u16, +) -> Result { + let view = oracle_feed.to_account_view(); + let data = view + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; + read_oracle_price(&data, expected_scale, slot, max_confidence_bps) +} + +/// The preamble `liquidate_position` and `update_price_average` run: read a +/// validated oracle price, checked for freshness against `slot`, bring the +/// pool's funding index up to `unix_timestamp`, and fold the interval since the +/// previous read into the pool's average (see `fold_price_into_average`), so +/// the settlement that follows uses fresh numbers. +/// Centralized so no handler can settle a position against a stale funding +/// index. +/// +/// No band check: liquidation has to keep working through a genuine price +/// move, because that is when positions go underwater, and +/// `update_price_average` is how the average catches up with one. pub fn refresh_price_and_funding( pool: &mut Account, oracle_feed: &UncheckedAccount, slot: u64, unix_timestamp: i64, ) -> Result { - let price = { - let view = oracle_feed.to_account_view(); - let data = view - .try_borrow() - .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; - read_oracle_price( - &data, - pool.oracle_scale.get(), - slot, - pool.max_confidence_bps.get(), - )? - }; + let price = read_pool_oracle_price(pool, oracle_feed, slot)?; + accrue_funding(pool, unix_timestamp)?; + fold_price_into_average(pool, price, unix_timestamp)?; + Ok(price) +} +/// The preamble for every handler that opens or closes a position or moves +/// liquidity: the same as `refresh_price_and_funding`, but first refuses a +/// price outside the band around the stored average, before anything is +/// folded in or the price is recorded. A single oracle print far from the +/// average therefore cannot open, close, deposit, or withdraw at that price. +pub fn refresh_price_and_funding_within_band( + pool: &mut Account, + oracle_feed: &UncheckedAccount, + slot: u64, + unix_timestamp: i64, +) -> Result { + let price = read_pool_oracle_price(pool, oracle_feed, slot)?; + require_price_within_band(pool, price)?; accrue_funding(pool, unix_timestamp)?; + fold_price_into_average(pool, price, unix_timestamp)?; Ok(price) } + +fn read_pool_oracle_price( + pool: &Account, + oracle_feed: &UncheckedAccount, + slot: u64, +) -> Result { + read_feed_price( + oracle_feed, + pool.oracle_scale.get(), + slot, + pool.max_confidence_bps.get(), + ) +} diff --git a/finance/perpetual-futures/quasar/src/instructions/update_price_average.rs b/finance/perpetual-futures/quasar/src/instructions/update_price_average.rs new file mode 100644 index 000000000..f7e82be1f --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/update_price_average.rs @@ -0,0 +1,34 @@ +use { + crate::{instructions::shared::refresh_price_and_funding, state::Pool}, + quasar_lang::{prelude::*, sysvars::clock::Clock}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct UpdatePriceAverage { + /// Anyone may update the average: the result depends only on the oracle + /// price and the clock, never on who calls. + pub caller: Signer, + #[account( + mut, + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + )] + pub pool: Account, + /// CHECK: bound to the pool via its seeds. + pub oracle_feed: UncheckedAccount, + pub collateral_mint: Account, + pub clock: Sysvar, +} + +#[inline(always)] +pub fn handle_update_price_average(accounts: &mut UpdatePriceAverage) -> Result<(), ProgramError> { + let slot = accounts.clock.slot.get(); + let unix_timestamp = accounts.clock.unix_timestamp.get(); + refresh_price_and_funding( + &mut accounts.pool, + &accounts.oracle_feed, + slot, + unix_timestamp, + )?; + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/lib.rs b/finance/perpetual-futures/quasar/src/lib.rs index b21d656ce..dfbfee805 100644 --- a/finance/perpetual-futures/quasar/src/lib.rs +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -40,10 +40,11 @@ mod quasar_perpetual_futures { funding_rate_per_second: u64, open_fee_bps: u16, close_fee_bps: u16, - max_leverage: u16, + initial_margin_bps: u16, maintenance_margin_bps: u16, liquidation_fee_bps: u16, max_confidence_bps: u16, + max_price_deviation_bps: u16, ) -> Result<(), ProgramError> { instructions::handle_initialize_pool( &mut ctx.accounts, @@ -51,10 +52,11 @@ mod quasar_perpetual_futures { funding_rate_per_second, open_fee_bps, close_fee_bps, - max_leverage, + initial_margin_bps, maintenance_margin_bps, liquidation_fee_bps, max_confidence_bps, + max_price_deviation_bps, &ctx.bumps, ) } @@ -122,4 +124,15 @@ mod quasar_perpetual_futures { pub fn collect_fees(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_collect_fees(&mut ctx.accounts, &ctx.bumps) } + + /// Read the oracle, credit the seconds since the previous read to the + /// price that read saw, record the current price for the next read, and + /// accrue funding up to now. Permissionless: after a genuine price move + /// takes the oracle outside the pool's band, anyone can call this + /// repeatedly as time passes to walk the average toward the new price until + /// trading resumes. + #[instruction(discriminator = 7)] + pub fn update_price_average(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_update_price_average(&mut ctx.accounts) + } } diff --git a/finance/perpetual-futures/quasar/src/state.rs b/finance/perpetual-futures/quasar/src/state.rs index b3828bbe7..dea3c04b2 100644 --- a/finance/perpetual-futures/quasar/src/state.rs +++ b/finance/perpetual-futures/quasar/src/state.rs @@ -32,17 +32,37 @@ pub struct Pool { /// the wall clock, so what a position costs per hour does not depend on /// the cluster's slot time. pub last_funding_timestamp: i64, + /// Time-weighted moving average of the oracle price, in the pool's + /// `oracle_scale` fixed point. Seeded with the oracle price when the pool is + /// created. Every handler that reads the oracle credits the seconds since + /// the previous read to `last_oracle_price`, the price that read saw. + /// Trading and liquidity handlers refuse an oracle price more than + /// `max_price_deviation_bps` away from it, so a sudden jump pauses them + /// until the average catches up. + pub average_price: u64, + /// The oracle price at the most recent read, in `oracle_scale` fixed point. + /// The next read folds it into `average_price` for the seconds in between. + pub last_oracle_price: u64, + /// The Clock's `unix_timestamp` of the most recent fold into + /// `average_price`. + pub average_price_timestamp: i64, /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. pub funding_rate_per_second: u64, pub open_fee_bps: u16, pub close_fee_bps: u16, - pub max_leverage: u16, + /// Net collateral a position must post to open, in basis points of its + /// notional size: 1_000 allows at most 10x leverage. Always above + /// `maintenance_margin_bps`, so no position opens already liquidatable. + pub initial_margin_bps: u16, pub maintenance_margin_bps: u16, pub liquidation_fee_bps: u16, /// Maximum oracle confidence band, in basis points of the price, the pool /// will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, + /// Widest gap the pool trades across between the oracle price and + /// `average_price`, in basis points of `average_price`. + pub max_price_deviation_bps: u16, pub bump: u8, } diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index e51ba9443..0ca5c8ad6 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -1,6 +1,7 @@ //! quasar-test integration tests. They exercise the full lifecycle: pool //! initialization, liquidity add/remove, opening/closing/liquidating leveraged -//! positions, fee collection, and the oracle/leverage/reserve guard rails. +//! positions, fee collection, the price average and its band, and the +//! oracle/margin/reserve checks. use { crate::{ @@ -8,8 +9,9 @@ use { cpi::{ AddLiquidityInstruction, ClosePositionInstruction, CollectFeesInstruction, InitializePoolInstruction, LiquidatePositionInstruction, OpenPositionInstruction, - RemoveLiquidityInstruction, + RemoveLiquidityInstruction, UpdatePriceAverageInstruction, }, + instructions::shared::error, state::{Pool, Position}, LpMintPda, VaultPda, }, @@ -42,6 +44,11 @@ const VICTIM_COLLATERAL: Pubkey = Pubkey::new_from_array([13; 32]); const VICTIM_LP: Pubkey = Pubkey::new_from_array([14; 32]); const OPERATOR_WALLET: Pubkey = Pubkey::new_from_array([15; 32]); const OPERATOR_COLLATERAL: Pubkey = Pubkey::new_from_array([16; 32]); +const KEEPER: Pubkey = Pubkey::new_from_array([17; 32]); + +// Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds +// replaces the pool's average price with the oracle price. +const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; // Ten years, in seconds. const TEN_YEARS: i64 = 315_360_000; @@ -115,18 +122,40 @@ fn init_pool_with_funding( funding_rate_per_second: u64, ) -> Outcome { test.send(InitializePoolInstruction { + maintenance_margin_bps, + close_fee_bps, + funding_rate_per_second, + ..default_initialize_pool() + }) +} + +/// The pool every test uses unless it overrides a parameter: 0.1% open and +/// close fees, a 10% initial margin (10x leverage), a 5% maintenance margin, a +/// 1% liquidation fee, a 1% maximum confidence band, a 20% price band around +/// the pool's average price, and no funding. +fn default_initialize_pool() -> InitializePoolInstruction { + InitializePoolInstruction { authority: ADMIN, collateral_mint: COLLATERAL_MINT, oracle_feed: FEED, oracle_scale: ORACLE_SCALE, - funding_rate_per_second, + funding_rate_per_second: 0, open_fee_bps: 10, - close_fee_bps, - max_leverage: 10, - maintenance_margin_bps, + close_fee_bps: 10, + initial_margin_bps: 1_000, + maintenance_margin_bps: 500, liquidation_fee_bps: 100, max_confidence_bps: 100, - }) + max_price_deviation_bps: 2_000, + } +} + +/// The world `initialize_pool` needs: the admin, the collateral mint, and a +/// feed at $100. +fn add_pool_prerequisites(test: &mut Test) { + test.add(Wallet::new().at(ADMIN)); + test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); + set_feed(test, dollars(100), 0); } /// The pool and its derived PDAs. @@ -137,8 +166,7 @@ struct Env { } /// Build a world with a collateral mint, an oracle feed at $100, and an -/// initialized pool (0.1% open/close fees, 10x max leverage, 5% maintenance -/// margin, 1% liquidation fee, 1% max confidence). +/// initialized pool with the parameters in `default_initialize_pool`. fn setup(test: &mut Test) -> Env { setup_with_funding(test, 0) } @@ -146,9 +174,7 @@ fn setup(test: &mut Test) -> Env { /// Like `setup`, but with a non-zero per-second funding rate so funding accrues /// as time passes. fn setup_with_funding(test: &mut Test, funding_rate_per_second: u64) -> Env { - test.add(Wallet::new().at(ADMIN)); - test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); - set_feed(test, dollars(100), 0); + add_pool_prerequisites(test); init_pool_with_funding(test, 500, 10, funding_rate_per_second).succeeds(); let pool = test.derive_pda(Pool::seeds(&COLLATERAL_MINT, &FEED)); @@ -209,6 +235,35 @@ fn open_position(test: &mut Test, env: &Env, side: u8, collateral: u64, size: u6 }) } +/// The pool's `(average_price, last_oracle_price, average_price_timestamp)`. +fn pool_state(test: &Test, env: &Env) -> (u64, u64, i64) { + let pool = test.read::(env.pool); + ( + u64::from(pool.average_price), + u64::from(pool.last_oracle_price), + i64::from(pool.average_price_timestamp), + ) +} + +/// Move the clock `seconds` past the pool's last average fold, publish `price` +/// at the new slot, and call `update_price_average`, which credits those +/// seconds to the price seen at the previous read and records `price`. +fn update_average_after(test: &mut Test, env: &Env, seconds: i64, price: i128) -> Outcome { + let (_, _, last_fold) = pool_state(test, env); + let timestamp = last_fold + seconds; + let slot = timestamp as u64 * SLOTS_PER_SECOND; + set_clock_at(test, slot, timestamp); + set_feed_at_slot(test, price, slot, 0); + if test.account(KEEPER).is_none() { + test.add(Wallet::new().at(KEEPER)); + } + test.send(UpdatePriceAverageInstruction { + caller: KEEPER, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + }) +} + fn close_position(test: &mut Test, env: &Env) -> Outcome { test.send(ClosePositionInstruction { owner: TRADER, @@ -409,16 +464,29 @@ fn close_long_in_profit_pays_collateral_plus_pnl_minus_fees(test: &mut Test) { } #[quasar_test] -fn open_rejects_excess_leverage(test: &mut Test) { +fn open_rejects_position_below_initial_margin(test: &mut Test) { let env = setup(test); fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 2_000 * ONE_USDC); - fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); - // 11x exceeds the 10x maximum. - assert!( - open_position(test, &env, 0, 1_000 * ONE_USDC, 11_000 * ONE_USDC).is_err(), - "11x leverage must be rejected" + // The initial margin is 10% of notional. 1,000 USDC of collateral less + // the 11 USDC open fee leaves 989 USDC, short of the 1,100 USDC an 11,000 + // USDC position needs. + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, 11_000 * ONE_USDC) + .fails_with(error::INITIAL_MARGIN_NOT_MET); + + // A 10,000 USDC position needs 1,000 USDC net of its 10 USDC open fee. + // One minor unit short of 1,010 USDC is refused, and exactly 1,010 USDC + // opens at 10x. + let size = 10_000 * ONE_USDC; + let exact_collateral = 1_010 * ONE_USDC; + open_position(test, &env, SIDE_LONG, exact_collateral - 1, size) + .fails_with(error::INITIAL_MARGIN_NOT_MET); + open_position(test, &env, SIDE_LONG, exact_collateral, size).succeeds(); + assert_eq!( + u64::from(test.read::(env.pool).total_collateral), + size / 10 ); } @@ -476,14 +544,6 @@ fn collect_fees_sweeps_the_open_fee_to_the_admin(test: &mut Test) { .has_tokens(ADMIN_COLLATERAL, size / 1_000); } -/// Retuning the rate settles the seconds already elapsed at the old rate -/// rather than repricing them at the new one. -/// -/// Both halves below hold the same position for the same seconds at the same -/// price, so the size and price scaling cancels and only the rates differ: the -/// spanning position pays one window at the old rate plus one at the new (3 -/// window-rates), and the position opened afterwards pays one window wholly at -/// the new rate (2 window-rates). /// Funding is quoted per second of wall-clock time, so slots passing without /// the clock moving charge nothing. A million extra slots halfway through the /// window, as a much shorter slot would produce, leave the funding unchanged. @@ -533,13 +593,9 @@ fn funding_follows_seconds_not_slots(test: &mut Test) { #[quasar_test] fn initialize_pool_rejects_funding_rate_above_the_maximum(test: &mut Test) { // The rate is fixed at creation, so this is the only place it is checked. - test.add(Wallet::new().at(ADMIN)); - test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); - set_feed(test, dollars(100), 0); - assert!( - init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND + 1).is_err(), - "a funding rate above the maximum must be rejected" - ); + add_pool_prerequisites(test); + init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND + 1) + .fails_with(error::INVALID_PARAMETER); init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND).succeeds(); } @@ -641,8 +697,13 @@ fn profit_is_capped_at_the_reserved_notional(test: &mut Test) { open_position(test, &env, 0, collateral, size).succeeds(); // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). + // profit is capped at the reserved notional (`size`). A move this large is + // far outside the price band, so the average has to catch up before the + // position can close: one update records $300, and a second a full window + // later credits that window to $300, replacing the average. set_feed(test, dollars(300), 0); + update_average_after(test, &env, 0, dollars(300)).succeeds(); + update_average_after(test, &env, PRICE_AVERAGE_WINDOW_SECONDS, dollars(300)).succeeds(); let open_fee = size / 1_000; let close_fee = size / 1_000; @@ -676,11 +737,261 @@ fn initialize_pool_rejects_close_fee_at_or_above_maintenance_margin(test: &mut T // A pool whose close fee reached the maintenance margin could strand a // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. - test.add(Wallet::new().at(ADMIN)); - test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); - set_feed(test, dollars(100), 0); - assert!( - init_pool(test, 500, 600).is_err(), - "close_fee_bps >= maintenance_margin_bps must be rejected" + add_pool_prerequisites(test); + init_pool(test, 500, 600).fails_with(error::INVALID_PARAMETER); +} + +#[quasar_test] +fn initialize_pool_records_the_margins_band_and_average(test: &mut Test) { + let env = setup(test); + let pool = test.read::(env.pool); + assert_eq!(u16::from(pool.initial_margin_bps), 1_000); + assert_eq!(u16::from(pool.max_price_deviation_bps), 2_000); + // The average starts at the oracle price the pool was created against. + assert_eq!(u64::from(pool.average_price), dollars(100) as u64); +} + +#[quasar_test] +fn initialize_pool_rejects_initial_margin_at_or_below_maintenance(test: &mut Test) { + // An initial margin at or below the 5% maintenance margin would let a + // position open already liquidatable. + add_pool_prerequisites(test); + for initial_margin_bps in [500, 350] { + test.send(InitializePoolInstruction { + initial_margin_bps, + ..default_initialize_pool() + }) + .fails_with(error::INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE); + } + // Above 100% of notional is refused too. + test.send(InitializePoolInstruction { + initial_margin_bps: 10_001, + ..default_initialize_pool() + }) + .fails_with(error::INVALID_PARAMETER); + // One basis point above the maintenance margin is accepted. + test.send(InitializePoolInstruction { + initial_margin_bps: 501, + ..default_initialize_pool() + }) + .succeeds(); +} + +#[quasar_test] +fn initialize_pool_rejects_price_deviation_outside_range(test: &mut Test) { + add_pool_prerequisites(test); + for max_price_deviation_bps in [0, 10_000] { + test.send(InitializePoolInstruction { + max_price_deviation_bps, + ..default_initialize_pool() + }) + .fails_with(error::INVALID_PRICE_DEVIATION); + } + test.send(InitializePoolInstruction { + max_price_deviation_bps: 9_999, + ..default_initialize_pool() + }) + .succeeds(); +} + +/// A single oracle print far from the pool's average cannot be traded at: the +/// open is refused before the price is folded into the average. +#[quasar_test] +fn open_rejected_when_oracle_jumps_outside_band(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); + let size = 5_000 * ONE_USDC; + + // The band is 20% around the $100 average: $125 and $79 are outside it. + for outside_price in [dollars(125), dollars(79)] { + set_feed(test, outside_price, 0); + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, size) + .fails_with(error::PRICE_OUTSIDE_BAND); + // The refused open folded nothing into the average. + assert_eq!(pool_state(test, &env).0, dollars(100) as u64); + } + + // $118 is inside the band, and opens at that price. + set_feed(test, dollars(118), 0); + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, size).succeeds(); + let position = test.read::(test.derive_pda(Position::seeds(&env.pool, &TRADER))); + assert_eq!(u64::from(position.entry_price), dollars(118) as u64); +} + +#[quasar_test] +fn close_rejected_when_oracle_jumps_outside_band(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + fund(test, TRADER, TRADER_COLLATERAL, collateral); + open_position(test, &env, SIDE_LONG, collateral, size).succeeds(); + + // A jump to $125 would pay the long $1,250, but $125 is 25% from the + // $100 average, outside the 20% band. + set_feed(test, dollars(125), 0); + close_position(test, &env).fails_with(error::PRICE_OUTSIDE_BAND); + + // At $115, inside the band, the close goes through and pays the 15% gain. + set_feed(test, dollars(115), 0); + let fee = size / 1_000; + let profit = size * 15 / 100; + close_position(test, &env) + .succeeds() + .has_tokens(TRADER_COLLATERAL, collateral - fee + profit - fee); +} + +/// Liquidation has no band check: a genuine crash is when positions go +/// underwater, so the pool has to be able to liquidate through one. +#[quasar_test] +fn liquidation_runs_outside_band(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_100 * ONE_USDC); + open_position(test, &env, SIDE_LONG, 1_100 * ONE_USDC, 10_000 * ONE_USDC).succeeds(); + + // $75 is 25% below the $100 average, so the owner cannot close there. + set_feed(test, dollars(75), 0); + close_position(test, &env).fails_with(error::PRICE_OUTSIDE_BAND); + + test.add(Wallet::new().at(LIQUIDATOR)); + let position = test.derive_pda(Position::seeds(&env.pool, &TRADER)); + test.send(LiquidatePositionInstruction { + liquidator: LIQUIDATOR, + owner: TRADER, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + trader_collateral: TRADER_COLLATERAL, + liquidator_collateral: LIQUIDATOR_COLLATERAL, + }) + .succeeds() + .is_closed(position); + assert_eq!(u128::from(test.read::(env.pool).long_size), 0); +} + +#[quasar_test] +fn liquidity_changes_rejected_when_oracle_jumps_outside_band(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 15_000 * ONE_USDC); + add_liquidity(test, &env, 10_000 * ONE_USDC).succeeds(); + let shares = test.tokens(PROVIDER_LP); + + // $76 is 24% below the $100 average. + set_feed(test, dollars(76), 0); + add_liquidity(test, &env, 5_000 * ONE_USDC).fails_with(error::PRICE_OUTSIDE_BAND); + remove_liquidity(test, &env, shares).fails_with(error::PRICE_OUTSIDE_BAND); +} + +/// After a genuine move outside the band, anyone can walk the average toward +/// the new price with `update_price_average`, and trading resumes once the +/// price is back inside the band. +#[quasar_test] +fn price_average_catches_up_after_genuine_move(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + fund(test, TRADER, TRADER_COLLATERAL, collateral); + + // NVDAx reprices from $100 to $130, 30% away from the average. + let new_price = dollars(130); + set_feed(test, new_price, 0); + open_position(test, &env, SIDE_LONG, collateral, size).fails_with(error::PRICE_OUTSIDE_BAND); + + // Every two minutes the keeper calls `update_price_average`. Each call + // credits the two minutes since the previous read to the price that read + // saw, a fifth of the window. The first call credits $100, the price + // before the move, and records $130; each later call moves the average a + // fifth of the remaining gap to $130: $100, then $106, then $110.80. $130 + // is within 20% of any average from $108.34 up, so the third update + // reopens trading. + let mut updates = 0; + loop { + update_average_after(test, &env, 120, new_price).succeeds(); + updates += 1; + let opened = open_position(test, &env, SIDE_LONG, collateral, size); + if opened.is_ok() { + break; + } + opened.fails_with(error::PRICE_OUTSIDE_BAND); + assert!(updates < 10, "the average never caught up"); + } + assert_eq!(updates, 3); + let (average_price, last_oracle_price, _) = pool_state(test, &env); + assert_eq!(average_price, 11_080_000_000); + assert_eq!(last_oracle_price, new_price as u64); +} + +#[quasar_test] +fn single_update_moves_average_by_elapsed_fraction(test: &mut Test) { + let env = setup(test); + let (_, _, created_at) = pool_state(test, &env); + + // The first update after the oracle moves to $115 credits the four + // minutes since creation to $100, the price seen at creation, so the + // average stays at $100 and $115 is recorded for the next read. + update_average_after(test, &env, 240, dollars(115)).succeeds(); + assert_eq!( + pool_state(test, &env), + (dollars(100) as u64, dollars(115) as u64, created_at + 240) ); + + // Four more minutes at $115 are 240 of the 600-second window, so the next + // update moves the average 240/600 of the way from $100 to $115: to $106. + update_average_after(test, &env, 240, dollars(115)).succeeds(); + assert_eq!( + pool_state(test, &env), + (dollars(106) as u64, dollars(115) as u64, created_at + 480) + ); + + // Fifteen minutes is more than a full window, so the next update replaces + // the average with $115, the price at the previous read, and records the + // fall to $97. One more update credits $97 for a full window. + update_average_after(test, &env, 900, dollars(97)).succeeds(); + assert_eq!( + pool_state(test, &env), + (dollars(115) as u64, dollars(97) as u64, created_at + 1_380) + ); + update_average_after(test, &env, 900, dollars(97)).succeeds(); + assert_eq!(pool_state(test, &env).0, dollars(97) as u64); +} + +/// A pool left idle for more than a window cannot have its average set by one +/// read of a manipulated price. The read only records the price; the interval +/// before it is credited to the price seen at the read before. Once a read of +/// the real price replaces it, the manipulated price has moved the average +/// only by the seconds between the two reads. +#[quasar_test] +fn one_manipulated_read_after_idle_does_not_move_average(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + let collateral = 1_000 * ONE_USDC; + fund(test, TRADER, TRADER_COLLATERAL, collateral); + + // Fifteen idle minutes, then the oracle is pushed to $160 and + // `update_price_average` is called. The average stays at $100. + update_average_after(test, &env, 900, dollars(160)).succeeds(); + let (average_price, last_oracle_price, _) = pool_state(test, &env); + assert_eq!(average_price, dollars(100) as u64); + assert_eq!(last_oracle_price, dollars(160) as u64); + + // Six seconds later the oracle is back at $100 and is read again. The six + // seconds are credited to $160: the average moves 6/600 of the $60 gap, + // to $100.60, and $100 replaces $160 as the latest observation. + update_average_after(test, &env, 6, dollars(100)).succeeds(); + let (average_price, last_oracle_price, last_fold) = pool_state(test, &env); + assert_eq!(average_price, 10_060_000_000); + assert_eq!(last_oracle_price, dollars(100) as u64); + + // An open at $160 is still refused. + set_feed_at_slot(test, dollars(160), last_fold as u64 * SLOTS_PER_SECOND, 0); + open_position(test, &env, SIDE_LONG, collateral, 5_000 * ONE_USDC) + .fails_with(error::PRICE_OUTSIDE_BAND); } From fe16e9448e00f6bfc7d6e01a21004264f178bb6a Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 20:37:45 +0000 Subject: [PATCH 5/5] Move the perpetual-futures changes to their own pull request This pull request is the fundraiser fix only. The perpetual-futures initial margin and price band are on claude/perps-margin-price-band. This reverts commit 1f1a46e0. Claude-Session: https://claude.ai/code/session_019G9tytYrS3Qp42fZ1hBnDu --- .../perpetual-futures/anchor-v1/CHANGELOG.md | 58 -- finance/perpetual-futures/anchor-v1/README.md | 42 +- .../anchor-v1/TERMINOLOGY.md | 43 +- .../perpetual-futures/src/constants.rs | 16 +- .../programs/perpetual-futures/src/errors.rs | 13 +- .../src/instructions/add_liquidity.rs | 4 +- .../src/instructions/close_position.rs | 6 +- .../src/instructions/initialize_pool.rs | 61 +- .../perpetual-futures/src/instructions/mod.rs | 2 - .../src/instructions/open_position.rs | 31 +- .../src/instructions/remove_liquidity.rs | 4 +- .../src/instructions/shared.rs | 110 +--- .../src/instructions/update_price_average.rs | 30 - .../programs/perpetual-futures/src/lib.rs | 17 +- .../perpetual-futures/src/state/pool.rs | 31 +- .../tests/test_perpetual_futures.rs | 519 +++--------------- finance/perpetual-futures/anchor/CHANGELOG.md | 58 -- finance/perpetual-futures/anchor/README.md | 42 +- .../perpetual-futures/anchor/TERMINOLOGY.md | 43 +- .../perpetual-futures/src/constants.rs | 16 +- .../programs/perpetual-futures/src/errors.rs | 13 +- .../src/instructions/add_liquidity.rs | 4 +- .../src/instructions/close_position.rs | 6 +- .../src/instructions/initialize_pool.rs | 61 +- .../perpetual-futures/src/instructions/mod.rs | 2 - .../src/instructions/open_position.rs | 31 +- .../src/instructions/remove_liquidity.rs | 4 +- .../src/instructions/shared.rs | 110 +--- .../src/instructions/update_price_average.rs | 30 - .../programs/perpetual-futures/src/lib.rs | 17 +- .../perpetual-futures/src/state/pool.rs | 31 +- .../tests/test_perpetual_futures.rs | 519 +++--------------- finance/perpetual-futures/quasar/CHANGELOG.md | 60 -- finance/perpetual-futures/quasar/README.md | 30 +- .../perpetual-futures/quasar/src/constants.rs | 13 +- .../quasar/src/instructions/add_liquidity.rs | 6 +- .../quasar/src/instructions/close_position.rs | 5 +- .../src/instructions/initialize_pool.rs | 43 +- .../quasar/src/instructions/mod.rs | 2 - .../quasar/src/instructions/open_position.rs | 31 +- .../src/instructions/remove_liquidity.rs | 6 +- .../quasar/src/instructions/shared.rs | 149 +---- .../src/instructions/update_price_average.rs | 34 -- finance/perpetual-futures/quasar/src/lib.rs | 17 +- finance/perpetual-futures/quasar/src/state.rs | 22 +- finance/perpetual-futures/quasar/src/tests.rs | 391 ++----------- 46 files changed, 366 insertions(+), 2417 deletions(-) delete mode 100644 finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs delete mode 100644 finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs delete mode 100644 finance/perpetual-futures/quasar/src/instructions/update_price_average.rs diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index 6e52940fb..f2ee8472b 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -1,63 +1,5 @@ # Changelog -## 2026-10-01 - -Replace the leverage cap with an initial margin. `max_leverage` on -`PoolParameters` and `Pool` is now `initial_margin_bps`, the net collateral a -position must post to open, in basis points of its size (1,000 is 10x). -`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= -10_000`, refusing an initial margin at or below the maintenance margin with the -new `InitialMarginNotAboveMaintenance` and one above 10,000 with -`InvalidParameter`; `MAX_LEVERAGE_CEILING` is removed. `open_position` checks -`net_collateral * 10_000 >= size * initial_margin_bps` and fails with -`InitialMarginNotMet`, which takes `LeverageTooHigh`'s place and its error code -(6004). Its separate check that a new position starts above the maintenance -margin is removed, because the initial margin implies it; `PositionNotHealthy` -remains for `close_position`. - -Add a price band around a program-maintained average price. A fresh, confident -oracle print could still be wrong, and every handler traded at it. The pool now -keeps `average_price`, a time-weighted moving average of the oracle price, -`last_oracle_price`, the price at the most recent oracle read, and -`average_price_timestamp`. `initialize_pool` seeds the average and -`last_oracle_price` from the oracle. Every handler that reads the oracle credits -the seconds since the previous read to the price that read saw, -`average += (last_oracle_price - average) * min(elapsed, -PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new -constant at 600 seconds, and then records the price it read as -`last_oracle_price`. The price read now only counts from now, so a pool left -idle for a window or more cannot have its average set by one read of a -manipulated price: that price moves the average only if the oracle still shows -it at a later read, weighted by the seconds between the two reads. -`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse -a price outside `|price - average_price| * 10_000 <= average_price * -max_price_deviation_bps` with the new `PriceOutsideBand`, checked against the -stored average before anything is folded in. `liquidate_position` folds and -records without the check. The new permissionless `update_price_average` -handler folds and records too, also without the check, so keepers calling it -repeatedly as time passes can walk the average to a genuine move. `max_price_deviation_bps` is a new -`PoolParameters` field, which `initialize_pool` requires to be above zero and -below 10,000 with the new `InvalidPriceDeviation`. `shared.rs` has -`refresh_price_and_funding_within_band` for the four band-checked handlers -beside `refresh_price_and_funding` for the other two. The `errors` module is -public so the tests can match `PerpError` codes. - -Tested by `test_open_rejects_position_below_initial_margin` (formerly -`test_open_rejects_excess_leverage`, now checking both sides of the boundary), -`test_initialize_pool_rejects_initial_margin_at_or_below_maintenance`, -`test_initialize_pool_rejects_price_deviation_outside_range`, -`test_open_rejected_when_oracle_jumps_outside_band`, -`test_close_rejected_when_oracle_jumps_outside_band`, -`test_liquidity_changes_rejected_when_oracle_jumps_outside_band`, -`test_liquidation_runs_outside_band`, -`test_price_average_catches_up_after_genuine_move`, -`test_single_update_moves_average_by_elapsed_fraction` and -`test_one_manipulated_read_after_idle_does_not_move_average`. The default test market -uses a 1,000 basis point initial margin and a 2,000 basis point band; -`test_profit_capped_at_reserved_notional` triples the price, far outside the -band, so it now calls `update_price_average` to record the new price, lets a -full window pass, and calls it again before closing. - ## 2026-09-30 Remove `set_funding_rate`. The pool's authority could change the funding rate at diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index 6eecb2174..d2e38e217 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -29,7 +29,7 @@ All arithmetic is integer `u128` with `checked_*` operations, multiplying before ### Long and short, leverage, collateral -A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size, and the pool's [initial margin](https://www.investopedia.com/terms/i/initialmargin.asp) caps their [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power): `open_position` requires the collateral left after the open fee to be at least `initial_margin_bps` of the size, checked as `net_collateral * 10_000 >= size * initial_margin_bps`, and fails with `InitialMarginNotMet` otherwise. An initial margin of 1,000 basis points (10%) allows at most 10× leverage. The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: +A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size up to the pool's maximum [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power). The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: ``` long profit/loss = size * (price - entry_price) / entry_price @@ -54,25 +54,10 @@ Funding runs on the wall clock rather than the slot count, so what a position co A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless: anyone can crank it and earn the liquidation fee. -`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= 10_000` and refuses anything else with `InitialMarginNotAboveMaintenance` (or `InvalidParameter` above 10,000). Every position therefore opens with more margin than it is liquidated at, so none can be liquidated in the slot it opened. - ### Oracle The mark price comes from an oracle feed. This example validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. -### Price band - -A single oracle print can be wrong while still being fresh, positive and confident: a publisher fault, or a thin market moved for a few seconds. To stop anyone trading against such a print, the pool keeps its own time-weighted moving average of the oracle price, `Pool.average_price`, and refuses prices too far from it. - -- `initialize_pool` reads the oracle and seeds both `average_price` and `last_oracle_price` with its price, stamping `average_price_timestamp` with the Clock's `unix_timestamp`. -- Every handler that reads the oracle credits the seconds since the previous read to the price that read saw, `last_oracle_price`, on the assumption that it held throughout: `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`. It then records the price it read as the new `last_oracle_price`. The window is 600 seconds, so a price seen at two reads six seconds apart moves the average by 1% of its gap from the average, and an interval of ten minutes or more replaces the average with the price seen at its start. -- The price read now only starts counting from now. A manipulated price moves the average only if the oracle still shows it at a later read, and only by the seconds between the two reads; a read of the real price in between replaces it. A pool left idle for longer than the window therefore cannot have its average set by a single read. -- `open_position`, `close_position`, `add_liquidity` and `remove_liquidity` first check the price against the stored average, before anything is folded in: `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. A price outside that band fails with `PriceOutsideBand`, and the pool is left unchanged. -- `liquidate_position` folds and records without the band check. A genuine crash is when positions go underwater, so liquidation keeps working through one. -- `update_price_average()` is permissionless: any signer passes the pool and its oracle feed, and the handler reads and validates the oracle with the same checks, accrues funding, folds the elapsed interval in and records the price, with no band check. After a genuine move takes the oracle outside the band, keepers call it repeatedly as time passes: the first call records the new price, and each later call credits the time since the previous one to it, until the average is close enough to the price for trading to resume. - -`max_price_deviation_bps` is fixed by `initialize_pool`, which refuses zero (every move would be refused) and 10,000 or more (a fall could never be refused, since prices are positive) with `InvalidPriceDeviation`. - ### Fees and slippage Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional and accrue to the program. Every state-changing handler takes a `minimum_*` / acceptable-price bound (protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill) and reverts if the bound is breached. Pass `0` to opt out. @@ -89,7 +74,7 @@ Open and close fees are charged in [basis points](https://www.investopedia.com/t - **Bob** (Short trader): He thinks NVDA will fall and wants to profit from the downside. - **Dave** (Liquidator): Runs a bot that closes under-margined positions to earn the liquidation fee. -Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). The pool is configured with a 10% initial margin (10× leverage), 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, a 1% maximum oracle confidence band, and a 20% price band around its average price. +Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). The pool is configured with 10× max leverage, 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, and a 1% maximum oracle confidence band. --- @@ -97,11 +82,9 @@ Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). T **Instruction:** `initialize_pool(parameters)` -The handler validates the parameters, then reads the oracle once to seed the pool's average price at $100. - **Accounts created:** -- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, average oracle price, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA +- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA - `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA, seeds `["vault", pool]`: all USDC, both provider liquidity and trader collateral; `pool` is its owner - `lp_mint` PDA, seeds `["lp_mint", pool]`: the share [mint](https://solana.com/docs/terminology#mint-account); `pool` is the mint authority @@ -154,7 +137,7 @@ While both are open, **funding** accrues to the pool from the heavier side; it i **Instruction:** `close_position(minimum_payout)` -$116 is 16% above the pool's $100 average price, inside the 20% band, so the close goes through. Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reserve cap), minus the $5 close fee. +Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reserve cap), minus the $5 close fee. **Accounts modified:** @@ -162,8 +145,6 @@ $116 is 16% above the pool's $100 average price, inside the 20% band, so the clo - `Pool.reserved_liquidity`: −$5,000 (reserve released) - `Pool.total_collateral`: −$995 - `Pool.program_fees`: +$5 -- `Pool.average_price`: credits the time since the last read to $100, the price that read saw, so it stays at $100 -- `Pool.last_oracle_price`: $100 → $116, which the next read credits for the time in between - long open-interest accumulators: −= this position - `custody_vault` → `alice_usdc`: pays out $1,790 (net collateral + profit − close fee) - `Position` (Alice): closed; rent returned to Alice @@ -214,7 +195,7 @@ The genuinely hard part of a perpetual-futures venue is keeping it solvent and p - **Account-local safety**: "every favorable action refreshes the account's full active portfolio first; … stale … legs fail closed." Here, every position and liquidity action reads a fresh oracle (stale or wide-confidence prices are rejected) and recomputes pool exposure before any payout. - **Bounded progress**: "no public instruction needs to evaluate the whole market." Here, assets-under-management comes from running per-side accumulators, and liquidation acts on one position at a time, so no handler's cost grows with the number of open positions. -What production pool-perps (`solana-labs/perpetuals`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, and valuing positions at the oracle's EMA rather than its spot price. This example keeps its own average only to decide when to refuse trading, and values positions at the spot price. +What production pool-perps (`solana-labs/perpetuals`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, and using the oracle's EMA for a less manipulable mark. --- @@ -231,18 +212,7 @@ This is a teaching example, not an audited exchange. Notably: ## Testing -The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover: - -- liquidity round-trips, and share inflation through a provider's own trades -- opening and closing longs and shorts in profit and loss -- the initial margin on both sides of its boundary, and slippage rejection -- stale-price, pre-restart-price, and wide-confidence rejection -- funding accrual, the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots -- the price band: opens, closes, deposits and withdrawals refused when the oracle jumps outside it (`test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band`, `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`), liquidation running outside it (`test_liquidation_runs_outside_band`), the exact average after each `update_price_average` (`test_single_update_moves_average_by_elapsed_fraction`), repeated updates walking the average to a genuine move until trading resumes (`test_price_average_catches_up_after_genuine_move`), and one manipulated read after an idle window leaving the average where it was (`test_one_manipulated_read_after_idle_does_not_move_average`) -- liquidation, and the refusal to liquidate a healthy position -- reserved-liquidity behaviour: profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity -- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin and a price band outside its range -- fee collection +The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price, pre-restart-price, and wide-confidence rejection, funding accrual, funding-rate retuning (including that it settles elapsed seconds at the old rate, and that only the authority may call it), funding that follows seconds rather than slots, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. ```bash anchor build diff --git a/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md b/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md index 55493ce89..26c7bd5e0 100644 --- a/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md +++ b/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md @@ -2,47 +2,36 @@ Terms used in this example, in the sense they carry here. -- **Perpetual future (perp)**: a leveraged derivative position with no expiry +- **Perpetual future (perp)** — a leveraged derivative position with no expiry and no settlement date. Profit and loss is paid in the collateral token as the oracle price moves. -- **Long / short**: a long profits when the price rises, a short when it falls. +- **Long / short** — a long profits when the price rises, a short when it falls. Each is the opposite side of the pool's exposure. -- **Collateral**: the token a trader posts to back a position, and the token +- **Collateral** — the token a trader posts to back a position, and the token liquidity providers deposit. One pool uses one collateral token. -- **Notional size**: the position's exposure in collateral units. Profit and +- **Notional size** — the position's exposure in collateral units. Profit and loss scales with the notional, not with the collateral posted. -- **Leverage**: notional size divided by collateral. A pool caps it through its - initial margin: 1,000 basis points (10%) allows at most 10x. -- **Initial margin**: the net collateral, as a fraction of notional size, a - position must post to open (`initial_margin_bps`). Always above the - maintenance margin, so no position opens already liquidatable. -- **Equity**: a position's current worth: net collateral plus unrealized profit +- **Leverage** — notional size divided by collateral. A pool caps it at + `max_leverage`. +- **Equity** — a position's current worth: net collateral plus unrealized profit and loss, minus accrued funding. When equity falls to the maintenance margin, the position is liquidatable. -- **Maintenance margin**: the minimum equity, as a fraction of notional size, +- **Maintenance margin** — the minimum equity, as a fraction of notional size, a position must keep to avoid liquidation. -- **Liquidation**: closing an under-margined position. Permissionless here: any +- **Liquidation** — closing an under-margined position. Permissionless here: any caller can trigger it and earns the liquidation fee. -- **Funding**: a periodic payment that anchors the pool's risk. The heavier +- **Funding** — a periodic payment that anchors the pool's risk. The heavier side of open interest pays funding to the pool over time. -- **Open interest**: the total notional size currently open on a side. -- **Liquidity provider**: a depositor who funds the pool and is the counterparty +- **Open interest** — the total notional size currently open on a side. +- **Liquidity provider** — a depositor who funds the pool and is the counterparty to every trade, earning fees in exchange for taking the other side of trader profit and loss. -- **Assets-under-management**: the marked value of liquidity-provider holdings: +- **Assets-under-management** — the marked value of liquidity-provider holdings: pool liquidity minus the aggregate unrealized profit traders are owed. -- **Liquidity-provider share**: a token representing a pro-rata claim on +- **Liquidity-provider share** — a token representing a pro-rata claim on assets-under-management. -- **Oracle feed**: the account the pool reads its price from. This example uses +- **Oracle feed** — the account the pool reads its price from. This example uses a mock oracle price feed; production points at a real one, such as a Pyth price feed. -- **Mark price**: the price positions are valued at. Here it is the oracle +- **Mark price** — the price positions are valued at. Here it is the oracle price directly, with no separate mark/index distinction. -- **Average price**: the pool's time-weighted moving average of the oracle - price (`average_price`), which follows the last ten minutes of prices. Each - oracle read credits the seconds since the previous read to the price that - read saw (`last_oracle_price`), so a price counts only from the read that - first sees it. Positions are never valued at it. -- **Price band**: the range around the average price, `max_price_deviation_bps` - wide on each side, outside which the pool refuses to open or close positions - or move liquidity. Liquidation is not refused. diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs index 706426f43..07bedc4fc 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs @@ -33,18 +33,10 @@ pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// lowers over time, so the window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; -/// How many seconds of oracle prices the pool's `average_price` follows. Each -/// fold moves the average toward the price seen at the previous read by -/// `elapsed / window` of the gap between them, and an interval of a full window -/// or more replaces the average with that price. Ten minutes is long enough -/// that a price seen at two reads six seconds apart, about as long as a faulty -/// or manipulated oracle print lasts, moves the average by one percent of its -/// jump, and short enough that a genuine move is back inside the band within -/// minutes of repeated reads. Counted on the Clock's `unix_timestamp`, -/// like funding: it is a span of wall-clock time, and the second or two of -/// leader drift changes a fold's weight by well under one percent. -#[constant] -pub const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; +/// Upper bound on the per-pool `max_leverage` parameter, so a pool cannot be +/// configured with an absurd leverage that makes every position instantly +/// liquidatable on the smallest price move. +pub const MAX_LEVERAGE_CEILING: u16 = 100; /// Upper bound on the per-pool `funding_rate_per_second` parameter, in /// `FUNDING_PRECISION` units: 277 billionths of a position's size per second, diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs index 5a18aa401..3f33f0c2c 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs @@ -14,8 +14,8 @@ pub enum PerpError { #[msg("Arithmetic overflow")] MathOverflow, - #[msg("Position is too large for its collateral: net collateral is below the pool's initial margin")] - InitialMarginNotMet, + #[msg("Requested leverage exceeds the pool maximum")] + LeverageTooHigh, #[msg("Pool parameter is outside the allowed range")] InvalidParameter, @@ -58,13 +58,4 @@ pub enum PerpError { #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, - - #[msg("Initial margin is at or below the maintenance margin: positions could open already liquidatable")] - InitialMarginNotAboveMaintenance, - - #[msg("Maximum price deviation is outside the allowed range: it must be above zero and below 10,000 basis points")] - InvalidPriceDeviation, - - #[msg("Oracle price is too far from the pool's average price: trading pauses until the average catches up")] - PriceOutsideBand, } diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs index 3ce6c4d3e..78af513ce 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; +use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding}; use crate::state::Pool; pub fn handle_add_liquidity( @@ -19,7 +19,7 @@ pub fn handle_add_liquidity( require!(amount > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply; let shares: u64 = if lp_supply == 0 && pool.liquidity == 0 { diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs index 0d0b9a8d5..61c741394 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs @@ -6,9 +6,7 @@ use anchor_spl::{ use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{ - basis_points_of, refresh_price_and_funding_within_band, settle_position, -}; +use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, settle_position}; use crate::state::{Pool, Position}; pub fn handle_close_position( @@ -16,7 +14,7 @@ pub fn handle_close_position( minimum_payout: u64, ) -> Result<()> { let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; let position = &context.accounts.position; let position_size = position.size; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs index a6c8ffe57..f87dafa8b 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -5,10 +5,10 @@ use anchor_spl::{ }; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, POOL_SEED, VAULT_SEED, + BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING, + POOL_SEED, VAULT_SEED, }; use crate::errors::PerpError; -use crate::state::oracle::read_oracle_price; use crate::state::Pool; /// Trading parameters set once at pool creation. None of them can be changed @@ -25,22 +25,12 @@ pub struct PoolParameters { pub open_fee_bps: u16, pub close_fee_bps: u16, - - /// Net collateral a position must post to open, in basis points of its - /// notional size. Must be above `maintenance_margin_bps` and at most - /// 10_000 (no leverage). - pub initial_margin_bps: u16, - + pub max_leverage: u16, pub maintenance_margin_bps: u16, pub liquidation_fee_bps: u16, /// Maximum oracle confidence band tolerated, in basis points of the price. pub max_confidence_bps: u16, - - /// Widest gap, in basis points of the pool's average price, between the - /// oracle price and that average at which positions may still open or - /// close and liquidity may still move. - pub max_price_deviation_bps: u16, } pub fn handle_initialize_pool( @@ -48,6 +38,10 @@ pub fn handle_initialize_pool( parameters: PoolParameters, ) -> Result<()> { let denominator = BASIS_POINTS_DENOMINATOR as u16; + require!( + parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING, + PerpError::InvalidParameter + ); // The rate never changes after this, so bounding it here bounds it for the // life of the pool. require!( @@ -81,40 +75,12 @@ pub fn handle_initialize_pool( parameters.maintenance_margin_bps > parameters.close_fee_bps, PerpError::InvalidParameter ); - // A position must open with more margin than it is liquidated at, or it - // could be liquidated in the same slot it opened. At most 100% of - // notional: more than that would demand collateral above the position's - // size. - require!( - parameters.initial_margin_bps > parameters.maintenance_margin_bps, - PerpError::InitialMarginNotAboveMaintenance - ); - require!( - parameters.initial_margin_bps <= denominator, - PerpError::InvalidParameter - ); // Zero would reject every real feed (which always reports some uncertainty); // above 100% is meaningless. Anything in between is a valid risk choice. require!( parameters.max_confidence_bps > 0 && parameters.max_confidence_bps < denominator, PerpError::InvalidParameter ); - // Zero would refuse every price move, however small. At 100% or more the - // band could never refuse a fall, since the oracle price is always - // positive. - require!( - parameters.max_price_deviation_bps > 0 && parameters.max_price_deviation_bps < denominator, - PerpError::InvalidPriceDeviation - ); - - // Seed the average with a validated oracle price, so the band is in force - // from the first trade. - let initial_price = read_oracle_price( - &context.accounts.oracle_feed, - parameters.oracle_scale, - parameters.max_confidence_bps, - )?; - let current_timestamp = Clock::get()?.unix_timestamp; let pool = &mut context.accounts.pool; pool.authority = context.accounts.authority.key(); @@ -132,18 +98,14 @@ pub fn handle_initialize_pool( pool.long_size_scaled = 0; pool.short_size_scaled = 0; pool.cumulative_funding = 0; - pool.last_funding_timestamp = current_timestamp; - pool.average_price = initial_price; - pool.last_oracle_price = initial_price; - pool.average_price_timestamp = current_timestamp; + pool.last_funding_timestamp = Clock::get()?.unix_timestamp; pool.funding_rate_per_second = parameters.funding_rate_per_second; pool.open_fee_bps = parameters.open_fee_bps; pool.close_fee_bps = parameters.close_fee_bps; - pool.initial_margin_bps = parameters.initial_margin_bps; + pool.max_leverage = parameters.max_leverage; pool.maintenance_margin_bps = parameters.maintenance_margin_bps; pool.liquidation_fee_bps = parameters.liquidation_fee_bps; pool.max_confidence_bps = parameters.max_confidence_bps; - pool.max_price_deviation_bps = parameters.max_price_deviation_bps; pool.bump = context.bumps.pool; Ok(()) @@ -166,9 +128,8 @@ pub struct InitializePoolAccountConstraints<'info> { pub collateral_mint: Box>, /// CHECK: The oracle feed account. Its key is stored on the pool and every - /// read, including the one here that seeds the average price, validates - /// the layout, scale, and freshness; it is never trusted by type. Swap for - /// a real Pyth price feed in production. + /// read validates the layout, scale, and freshness; it is never trusted by + /// type. Swap for a real Pyth price feed in production. pub oracle_feed: UncheckedAccount<'info>, /// Liquidity-provider share mint. The pool account is its mint authority diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs index 33c621dee..88337e1df 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs @@ -6,7 +6,6 @@ pub mod liquidate_position; pub mod open_position; pub mod remove_liquidity; pub mod shared; -pub mod update_price_average; pub use add_liquidity::*; pub use close_position::*; @@ -15,4 +14,3 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; -pub use update_price_average::*; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs index 0e1554c0f..45b27ac47 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs @@ -4,11 +4,9 @@ use anchor_spl::{ token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, }; -use crate::constants::{BASIS_POINTS_DENOMINATOR, POOL_SEED, POSITION_SEED, VAULT_SEED}; +use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{ - basis_points_of, refresh_price_and_funding_within_band, scale_size, -}; +use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, scale_size}; use crate::state::{Pool, Position, Side}; pub fn handle_open_position( @@ -21,7 +19,7 @@ pub fn handle_open_position( require!(collateral_amount > 0 && size > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; // Slippage: a long must not fill above the caller's limit, a short not // below it. `0` opts out. @@ -34,28 +32,21 @@ pub fn handle_open_position( } // The open fee is taken out of the posted collateral; the rest backs the - // position, and the initial margin is measured against this net collateral. + // position. Leverage and margin are measured against this net collateral. let open_fee = basis_points_of(size, pool.open_fee_bps)?; let net_collateral = collateral_amount .checked_sub(open_fee) .ok_or(PerpError::InsufficientCollateral)?; require!(net_collateral > 0, PerpError::ZeroAmount); - // Initial margin: net collateral must be at least `initial_margin_bps` of - // the notional size, compared as `net_collateral * 10_000 >= size * bps` - // so nothing is rounded. `initialize_pool` keeps the initial margin above - // the maintenance margin, so a position that passes this check opens with - // equity above the liquidation threshold. - let collateral_scaled = (net_collateral as u128) - .checked_mul(BASIS_POINTS_DENOMINATOR as u128) - .ok_or(PerpError::MathOverflow)?; - let required_scaled = (size as u128) - .checked_mul(pool.initial_margin_bps as u128) + let max_notional = (net_collateral as u128) + .checked_mul(pool.max_leverage as u128) .ok_or(PerpError::MathOverflow)?; - require!( - collateral_scaled >= required_scaled, - PerpError::InitialMarginNotMet - ); + require!(size as u128 <= max_notional, PerpError::LeverageTooHigh); + + // Refuse a position that would open already inside the liquidation band. + let maintenance = basis_points_of(size, pool.maintenance_margin_bps)?; + require!(net_collateral > maintenance, PerpError::PositionNotHealthy); // Reserve liquidity to cover this position's maximum recoverable profit // (its notional `size`). The reserve must be backed by liquidity-provider diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs index 500c119d7..e35237279 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; +use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding}; use crate::state::Pool; pub fn handle_remove_liquidity( @@ -19,7 +19,7 @@ pub fn handle_remove_liquidity( require!(shares > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply; let aum = liquidity_provider_aum(pool, price)?; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs index 53e9665e4..af20cdfb4 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs @@ -1,8 +1,6 @@ use anchor_lang::prelude::*; -use crate::constants::{ - BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, PRICE_AVERAGE_WINDOW_SECONDS, SIZE_PRECISION, -}; +use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; use crate::errors::PerpError; use crate::state::{Pool, Position, Side}; @@ -218,102 +216,16 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { .map_err(|_| PerpError::MathOverflow.into()) } -/// Fold the elapsed interval into the pool's `average_price`, then record -/// `price` as the latest observation. -/// -/// The interval since the last fold is credited to the price observed at -/// that fold, `last_oracle_price`, on the assumption that it held throughout: -/// -/// `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS` -/// -/// The price read now only starts counting from now, so it moves the average -/// only if it is still the oracle's price at a later read, weighted by the -/// seconds between the two reads; a read of a different price in between -/// replaces it. A pool left idle for a window or more therefore cannot have -/// its average set by one read. As with funding, a timestamp at or before the -/// stored one is treated as no time elapsed: the average and the stored stamp -/// stay where they are, and only `last_oracle_price` is updated. -pub fn fold_price_into_average(pool: &mut Pool, price: u64, current_timestamp: i64) -> Result<()> { - if current_timestamp <= pool.average_price_timestamp { - pool.last_oracle_price = price; - return Ok(()); - } - let elapsed = current_timestamp - .checked_sub(pool.average_price_timestamp) - .ok_or(PerpError::MathOverflow)?; - let weight = elapsed.min(PRICE_AVERAGE_WINDOW_SECONDS); - - let average = pool.average_price as i128; - // Multiply before dividing; the gap is signed, so the average moves down - // as readily as up. - let movement = (pool.last_oracle_price as i128) - .checked_sub(average) - .ok_or(PerpError::MathOverflow)? - .checked_mul(weight as i128) - .ok_or(PerpError::MathOverflow)? - .checked_div(PRICE_AVERAGE_WINDOW_SECONDS as i128) - .ok_or(PerpError::MathOverflow)?; - pool.average_price = average - .checked_add(movement) - .ok_or(PerpError::MathOverflow)? - .try_into() - .map_err(|_| PerpError::MathOverflow)?; - pool.last_oracle_price = price; - pool.average_price_timestamp = current_timestamp; - Ok(()) -} - -/// Refuse an oracle `price` more than `max_price_deviation_bps` away from the -/// pool's stored `average_price`: -/// `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. -pub fn require_price_within_band(pool: &Pool, price: u64) -> Result<()> { - let deviation_scaled = (price.abs_diff(pool.average_price) as u128) - .checked_mul(BASIS_POINTS_DENOMINATOR as u128) - .ok_or(PerpError::MathOverflow)?; - let band_scaled = (pool.average_price as u128) - .checked_mul(pool.max_price_deviation_bps as u128) - .ok_or(PerpError::MathOverflow)?; - require!(deviation_scaled <= band_scaled, PerpError::PriceOutsideBand); - Ok(()) -} - -/// The preamble `liquidate_position` and `update_price_average` run: read a -/// validated oracle price, bring the pool's funding index up to the current -/// time, and fold the interval since the previous read into the pool's average -/// (see `fold_price_into_average`), so the settlement that follows uses fresh -/// numbers. Centralized so no handler can settle a position -/// against a stale funding index. -/// -/// No band check: liquidation has to keep working through a genuine price -/// move, because that is when positions go underwater, and -/// `update_price_average` is how the average catches up with one. +/// The preamble every price-sensitive handler runs: read a validated oracle +/// price, then bring the pool's funding index up to the current time, so the +/// settlement that follows uses fresh numbers for both. Centralized so no +/// handler can settle a position against a stale funding index. pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountInfo) -> Result { - let price = read_pool_oracle_price(pool, oracle_feed)?; - apply_price_and_funding(pool, price)?; - Ok(price) -} - -/// The preamble for every handler that opens or closes a position or moves -/// liquidity: the same as `refresh_price_and_funding`, but first refuses a -/// price outside the band around the stored average, before anything is -/// folded in or the price is recorded. A single oracle print far from the -/// average therefore cannot open, close, deposit, or withdraw at that price. -pub fn refresh_price_and_funding_within_band( - pool: &mut Pool, - oracle_feed: &AccountInfo, -) -> Result { - let price = read_pool_oracle_price(pool, oracle_feed)?; - require_price_within_band(pool, price)?; - apply_price_and_funding(pool, price)?; + let price = crate::state::oracle::read_oracle_price( + oracle_feed, + pool.oracle_scale, + pool.max_confidence_bps, + )?; + accrue_funding(pool, Clock::get()?.unix_timestamp)?; Ok(price) } - -fn read_pool_oracle_price(pool: &Pool, oracle_feed: &AccountInfo) -> Result { - crate::state::oracle::read_oracle_price(oracle_feed, pool.oracle_scale, pool.max_confidence_bps) -} - -fn apply_price_and_funding(pool: &mut Pool, price: u64) -> Result<()> { - let current_timestamp = Clock::get()?.unix_timestamp; - accrue_funding(pool, current_timestamp)?; - fold_price_into_average(pool, price, current_timestamp) -} diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs deleted file mode 100644 index 8e46535bc..000000000 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs +++ /dev/null @@ -1,30 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::constants::POOL_SEED; -use crate::instructions::shared::refresh_price_and_funding; -use crate::state::Pool; - -pub fn handle_update_price_average( - context: Context, -) -> Result<()> { - refresh_price_and_funding(&mut context.accounts.pool, &context.accounts.oracle_feed)?; - Ok(()) -} - -#[derive(Accounts)] -pub struct UpdatePriceAverageAccountConstraints<'info> { - /// Anyone may update the average: the result depends only on the oracle - /// price and the clock, never on who calls. - pub caller: Signer<'info>, - - #[account( - mut, - seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], - bump = pool.bump, - has_one = oracle_feed, - )] - pub pool: Box>, - - /// CHECK: validated by the `has_one = oracle_feed` constraint on the pool. - pub oracle_feed: UncheckedAccount<'info>, -} diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs index 7c2bb1edb..a7487816c 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs @@ -1,10 +1,9 @@ use anchor_lang::prelude::*; mod constants; +mod errors; // Public so the LiteSVM integration tests can build instruction arguments -// (`PoolParameters`, `Side`) against the program's own types, and match -// failures against `PerpError` codes. -pub mod errors; +// (`PoolParameters`, `Side`) against the program's own types. pub mod instructions; pub mod state; @@ -76,18 +75,6 @@ pub mod perpetual_futures { instructions::handle_liquidate_position(context) } - /// Read the oracle, credit the seconds since the previous read to the - /// price that read saw, record the current price for the next read, and - /// accrue funding up to now. Permissionless: after a genuine price move - /// takes the oracle outside the pool's band, anyone can call this - /// repeatedly as time passes to walk the average toward the new price until - /// trading resumes. - pub fn update_price_average( - context: Context, - ) -> Result<()> { - instructions::handle_update_price_average(context) - } - /// The pool operator sweeps the accumulated program fees from the vault. pub fn collect_fees(context: Context) -> Result<()> { instructions::handle_collect_fees(context) diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs index 365353024..46f8831a7 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs @@ -71,23 +71,6 @@ pub struct Pool { /// the cluster's slot time. pub last_funding_timestamp: i64, - /// Time-weighted moving average of the oracle price, in the pool's - /// `oracle_scale` fixed point. Seeded with the oracle price when the pool is - /// created. Every handler that reads the oracle credits the seconds since - /// the previous read to `last_oracle_price`, the price that read saw. - /// Trading and liquidity handlers refuse an oracle price more than - /// `max_price_deviation_bps` away from it, so a sudden jump pauses them - /// until the average catches up. - pub average_price: u64, - - /// The oracle price at the most recent read, in `oracle_scale` fixed point. - /// The next read folds it into `average_price` for the seconds in between. - pub last_oracle_price: u64, - - /// The Clock's `unix_timestamp` of the most recent fold into - /// `average_price`. - pub average_price_timestamp: i64, - /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. pub funding_rate_per_second: u64, @@ -97,13 +80,11 @@ pub struct Pool { pub close_fee_bps: u16, - /// Net collateral a position must post to open, in basis points of its - /// notional size: 1_000 allows at most 10x leverage. Always above - /// `maintenance_margin_bps`, so no position opens already liquidatable. - pub initial_margin_bps: u16, + /// Highest leverage a position may open at (`size <= collateral * max`). + pub max_leverage: u16, - /// Equity threshold, in basis points of notional, at or below which a - /// position is liquidatable. + /// Equity threshold, in basis points of notional, below which a position is + /// liquidatable. pub maintenance_margin_bps: u16, /// Reward paid to a liquidator, in basis points of the liquidated notional. @@ -113,10 +94,6 @@ pub struct Pool { /// pool will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, - /// Widest gap the pool trades across between the oracle price and - /// `average_price`, in basis points of `average_price`. - pub max_price_deviation_bps: u16, - /// Bump of this account's own address. The pool owns the custody vault /// and is the LP mint's authority, so it signs vault transfers and /// mint/burn CPIs with `[POOL_SEED, collateral_mint, oracle_feed, bump]`; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs index ef1d5dfda..628769641 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -4,11 +4,7 @@ use { AccountDeserialize, InstructionData, ToAccountMetas, }, litesvm::LiteSVM, - perpetual_futures::{ - errors::PerpError, - instructions::initialize_pool::PoolParameters, - state::{Pool, Position, Side}, - }, + perpetual_futures::{instructions::initialize_pool::PoolParameters, state::Pool, state::Side}, solana_keypair::Keypair, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, @@ -21,9 +17,6 @@ use { // Matches `MAX_FUNDING_RATE_PER_SECOND` in the program's constants: the // steepest funding rate `initialize_pool` accepts. const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; -// Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds -// replaces the pool's average price with the oracle price. -const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; // Ten years, in seconds. const TEN_YEARS: i64 = 315_360_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 @@ -59,37 +52,6 @@ fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } -/// The parameters every test market uses unless a test overrides one: 0.1% -/// open and close fees, a 10% initial margin (10x leverage), a 5% maintenance -/// margin, a 1% liquidation fee, a 1% maximum confidence band, and a 20% price -/// band around the pool's average price. -fn default_parameters(funding_rate_per_second: u64) -> PoolParameters { - PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - open_fee_bps: 10, - close_fee_bps: 10, - initial_margin_bps: 1_000, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, - max_price_deviation_bps: 2_000, - } -} - -/// Assert that `result` failed with the program's `expected` error. Anchor -/// reports a program error as `Custom(6000 + the variant's index)`. -fn assert_fails_with(result: Result, expected: PerpError) { - let code = expected as u32 + 6000; - let Err(error) = result else { - panic!("the transaction should have failed with error code {code}"); - }; - assert!( - error.contains(&format!("Custom({code})")), - "expected error code {code}, got: {error}" - ); -} - /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -107,14 +69,23 @@ impl Market { /// funding rate. The admin is both the pool operator and the oracle feed /// authority. fn new(initial_price: i128, funding_rate_per_second: u64) -> Market { - Market::try_new(initial_price, default_parameters(funding_rate_per_second)) - .expect("pool initialization should succeed") + let parameters = PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + max_leverage: 10, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + }; + Market::try_new(initial_price, parameters).expect("pool initialization should succeed") } /// Like `new`, but takes the full parameter set and surfaces an /// `initialize_pool` rejection instead of panicking, so tests can probe the /// parameter validation. - fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { + fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { let mut svm = LiteSVM::new(); svm.add_program( perpetual_futures::id(), @@ -196,7 +167,7 @@ impl Market { &[&admin], &admin.pubkey(), ) - .map_err(|error| format!("{error:?}"))?; + .map_err(|_| ())?; Ok(Market { svm, @@ -304,7 +275,7 @@ impl Market { provider_collateral: Pubkey, amount: u64, minimum_shares_out: u64, - ) -> Result<(), String> { + ) -> Result<(), ()> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -334,7 +305,8 @@ impl Market { &[provider], &provider.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } fn remove_liquidity( @@ -343,7 +315,7 @@ impl Market { provider_collateral: Pubkey, shares: u64, minimum_amount_out: u64, - ) -> Result<(), String> { + ) -> Result<(), ()> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -373,7 +345,8 @@ impl Market { &[provider], &provider.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } fn position_pda(&self, owner: &Pubkey, side: Side) -> Pubkey { @@ -396,7 +369,7 @@ impl Market { collateral_amount: u64, size: u64, acceptable_price: u64, - ) -> Result<(), String> { + ) -> Result<(), ()> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -427,7 +400,8 @@ impl Market { &[trader], &trader.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } fn close_position( @@ -436,7 +410,7 @@ impl Market { trader_collateral: Pubkey, side: Side, minimum_payout: u64, - ) -> Result<(), String> { + ) -> Result<(), ()> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -461,7 +435,8 @@ impl Market { &[trader], &trader.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } fn liquidate( @@ -470,7 +445,7 @@ impl Market { owner: &Pubkey, owner_collateral: Pubkey, side: Side, - ) -> Result<(), String> { + ) -> Result<(), ()> { let position = self.position_pda(owner, side); let liquidator_collateral = derive_ata(&liquidator.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( @@ -498,10 +473,11 @@ impl Market { &[liquidator], &liquidator.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } - fn collect_fees(&mut self, authority: &Keypair) -> Result<(), String> { + fn collect_fees(&mut self, authority: &Keypair) -> Result<(), ()> { let authority_collateral = derive_ata(&authority.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -524,40 +500,8 @@ impl Market { &[authority], &authority.pubkey(), ) - .map_err(|error| format!("{error:?}")) - } - - fn update_price_average(&mut self, caller: &Keypair) -> Result<(), String> { - let instruction = Instruction::new_with_bytes( - perpetual_futures::id(), - &perpetual_futures::instruction::UpdatePriceAverage {}.data(), - perpetual_futures::accounts::UpdatePriceAverageAccountConstraints { - caller: caller.pubkey(), - pool: self.pool, - oracle_feed: self.feed, - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut self.svm, - vec![instruction], - &[caller], - &caller.pubkey(), - ) - .map_err(|error| format!("{error:?}")) - } - - /// Hold the oracle at `price` while the pool's average catches up with - /// it: one update records `price` as the latest observation, then a full - /// averaging window passes with the price republished so it is fresh, and - /// a second update credits that window to `price`. A price more than the - /// band away from the average cannot be traded at until this has run. - fn settle_average_at(&mut self, price: i128) { - let caller = self.payer.insecure_clone(); - self.update_price_average(&caller).unwrap(); - self.pass_seconds(PRICE_AVERAGE_WINDOW_SECONDS); - self.set_price(price); - self.update_price_average(&caller).unwrap(); + .map(|_| ()) + .map_err(|_| ()) } /// Deposit a large amount of liquidity so the pool can pay trader profits, @@ -579,17 +523,7 @@ fn test_initialize_pool() { assert_eq!(pool.collateral_mint, market.collateral_mint); assert_eq!(pool.oracle_feed, market.feed); assert_eq!(pool.oracle_scale, ORACLE_SCALE); - assert_eq!(pool.initial_margin_bps, 1_000); - assert_eq!(pool.max_price_deviation_bps, 2_000); - // The average starts at the oracle price the pool was created against. - assert_eq!(pool.average_price, dollars(100) as u64); - assert_eq!( - pool.average_price_timestamp, - market - .svm - .get_sysvar::() - .unix_timestamp - ); + assert_eq!(pool.max_leverage, 10); assert_eq!(pool.liquidity, 0); assert_eq!(pool.total_collateral, 0); @@ -915,53 +849,17 @@ fn test_open_rejects_zero_amounts() { } #[test] -fn test_open_rejects_position_below_initial_margin() { +fn test_open_rejects_excess_leverage() { let mut market = Market::default_market(); market.seed_liquidity(100_000 * ONE_USDC); - let (trader, trader_collateral) = market.funded_trader(2_000 * ONE_USDC); - - // The initial margin is 10% of notional. 1,000 USDC of collateral less - // the 11 USDC open fee leaves 989 USDC, short of the 1,100 USDC an 11,000 - // USDC position needs. - assert_fails_with( - market.open_position( - &trader, - trader_collateral, - Side::Long, - 1_000 * ONE_USDC, - 11_000 * ONE_USDC, - 0, - ), - PerpError::InitialMarginNotMet, - ); + let collateral = 1_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); - // A 10,000 USDC position needs 1,000 USDC net of its 10 USDC open fee. - // One minor unit short of 1,010 USDC is refused, and exactly 1,010 USDC - // opens at 10x. - let size = 10_000 * ONE_USDC; - let exact_collateral = 1_010 * ONE_USDC; - assert_fails_with( - market.open_position( - &trader, - trader_collateral, - Side::Long, - exact_collateral - 1, - size, - 0, - ), - PerpError::InitialMarginNotMet, - ); - market - .open_position( - &trader, - trader_collateral, - Side::Long, - exact_collateral, - size, - 0, - ) - .unwrap(); - assert_eq!(market.pool_state().total_collateral, size / 10); + // max_leverage is 10x; 11x must be rejected. + let size = 11_000 * ONE_USDC; + assert!(market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .is_err()); } #[test] @@ -1163,18 +1061,18 @@ fn test_funding_follows_seconds_not_slots() { #[test] fn test_initialize_pool_rejects_funding_rate_above_the_maximum() { // The rate is fixed at creation, so this is the only place it is checked. - assert_fails_with( - Market::try_new( - dollars(100), - default_parameters(MAX_FUNDING_RATE_PER_SECOND + 1), - ), - PerpError::InvalidParameter, - ); - assert!(Market::try_new( - dollars(100), - default_parameters(MAX_FUNDING_RATE_PER_SECOND) - ) - .is_ok()); + let parameters = |funding_rate_per_second| PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + max_leverage: 10, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + }; + assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND + 1)).is_err()); + assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND)).is_ok()); } /// The pool operator trading against their own pool. The lighter side of open @@ -1402,11 +1300,8 @@ fn test_profit_capped_at_reserved_notional() { .unwrap(); // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). A move this large is - // far outside the price band, so the average has to catch up before the - // position can close. + // profit is capped at the reserved notional (`size`). market.set_price(dollars(300)); - market.settle_average_at(dollars(300)); market .close_position(&trader, trader_collateral, Side::Long, 0) .unwrap(); @@ -1455,304 +1350,14 @@ fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. let parameters = PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second: 0, + open_fee_bps: 10, close_fee_bps: 600, - ..default_parameters(0) - }; - assert_fails_with( - Market::try_new(dollars(100), parameters), - PerpError::InvalidParameter, - ); -} - -#[test] -fn test_initialize_pool_rejects_initial_margin_at_or_below_maintenance() { - // An initial margin at or below the 5% maintenance margin would let a - // position open already liquidatable. - let with_initial_margin = |initial_margin_bps| PoolParameters { - initial_margin_bps, - ..default_parameters(0) - }; - assert_fails_with( - Market::try_new(dollars(100), with_initial_margin(500)), - PerpError::InitialMarginNotAboveMaintenance, - ); - assert_fails_with( - Market::try_new(dollars(100), with_initial_margin(350)), - PerpError::InitialMarginNotAboveMaintenance, - ); - - // Above 100% of notional is refused too. One basis point above the - // maintenance margin, and exactly 100%, are accepted. - assert_fails_with( - Market::try_new(dollars(100), with_initial_margin(10_001)), - PerpError::InvalidParameter, - ); - assert!(Market::try_new(dollars(100), with_initial_margin(501)).is_ok()); - assert!(Market::try_new(dollars(100), with_initial_margin(10_000)).is_ok()); -} - -#[test] -fn test_initialize_pool_rejects_price_deviation_outside_range() { - let with_deviation = |max_price_deviation_bps| PoolParameters { - max_price_deviation_bps, - ..default_parameters(0) + max_leverage: 10, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, }; - for rejected in [0, 10_000] { - assert_fails_with( - Market::try_new(dollars(100), with_deviation(rejected)), - PerpError::InvalidPriceDeviation, - ); - } - assert!(Market::try_new(dollars(100), with_deviation(1)).is_ok()); - assert!(Market::try_new(dollars(100), with_deviation(9_999)).is_ok()); -} - -/// A single oracle print far from the pool's average cannot be traded at: the -/// open is refused before the price is folded into the average. -#[test] -fn test_open_rejected_when_oracle_jumps_outside_band() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - - // The band is 20% around the $100 average: $125 and $79 are outside it. - for outside_price in [dollars(125), dollars(79)] { - market.set_price(outside_price); - // The two refused opens are otherwise byte-identical transactions. - market.svm.expire_blockhash(); - assert_fails_with( - market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), - PerpError::PriceOutsideBand, - ); - // The refused open folded nothing into the average. - assert_eq!(market.pool_state().average_price, dollars(100) as u64); - } - - // $118 is inside the band, and opens at that price. - market.set_price(dollars(118)); - market.svm.expire_blockhash(); - market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .unwrap(); - let position_account = market - .svm - .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) - .unwrap(); - let position = Position::try_deserialize(&mut position_account.data.as_slice()).unwrap(); - assert_eq!(position.entry_price, dollars(118) as u64); -} - -#[test] -fn test_close_rejected_when_oracle_jumps_outside_band() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .unwrap(); - - // A jump to $125 would pay the long $1,250, but $125 is 25% from the - // $100 average, outside the 20% band. - market.set_price(dollars(125)); - assert_fails_with( - market.close_position(&trader, trader_collateral, Side::Long, 0), - PerpError::PriceOutsideBand, - ); - - // At $115, inside the band, the close goes through and pays the 15% gain. - market.set_price(dollars(115)); - market.svm.expire_blockhash(); - market - .close_position(&trader, trader_collateral, Side::Long, 0) - .unwrap(); - let fee = size / 1_000; - let profit = size * 15 / 100; - assert_eq!( - get_token_account_balance(&market.svm, &trader_collateral).unwrap(), - collateral - fee + profit - fee - ); -} - -/// Liquidation has no band check: a genuine crash is when positions go -/// underwater, so the pool has to be able to liquidate through one. -#[test] -fn test_liquidation_runs_outside_band() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_100 * ONE_USDC; - let size = 10_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .unwrap(); - - // $75 is 25% below the $100 average, so the owner cannot close there. - market.set_price(dollars(75)); - assert_fails_with( - market.close_position(&trader, trader_collateral, Side::Long, 0), - PerpError::PriceOutsideBand, - ); - - let liquidator = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); - market - .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) - .unwrap(); - assert!(market - .svm - .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) - .is_none()); - assert_eq!(market.pool_state().long_size, 0); -} - -#[test] -fn test_liquidity_changes_rejected_when_oracle_jumps_outside_band() { - let mut market = Market::default_market(); - let (provider, provider_collateral) = market.seed_liquidity(10_000 * ONE_USDC); - let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); - let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); - - // $76 is 24% below the $100 average. - market.set_price(dollars(76)); - let (depositor, depositor_collateral) = market.funded_trader(5_000 * ONE_USDC); - assert_fails_with( - market.add_liquidity(&depositor, depositor_collateral, 5_000 * ONE_USDC, 0), - PerpError::PriceOutsideBand, - ); - assert_fails_with( - market.remove_liquidity(&provider, provider_collateral, shares, 0), - PerpError::PriceOutsideBand, - ); -} - -/// After a genuine move outside the band, anyone can walk the average toward -/// the new price with `update_price_average`, and trading resumes once the -/// price is back inside the band. -#[test] -fn test_price_average_catches_up_after_genuine_move() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); - - // NVDAx reprices from $100 to $130, 30% away from the average. - let new_price = dollars(130); - market.set_price(new_price); - assert_fails_with( - market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), - PerpError::PriceOutsideBand, - ); - - // Every two minutes the keeper calls `update_price_average`. Each call - // credits the two minutes since the previous read to the price that read - // saw, a fifth of the window. The first call credits $100, the price - // before the move, and records $130; each later call moves the average a - // fifth of the remaining gap to $130: $100, then $106, then $110.80. $130 - // is within 20% of any average from $108.34 up, so the third update - // reopens trading. - let mut updates = 0; - loop { - market.pass_seconds(120); - market.set_price(new_price); - market.update_price_average(&keeper).unwrap(); - updates += 1; - let opened = - market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0); - if opened.is_ok() { - break; - } - assert_fails_with(opened, PerpError::PriceOutsideBand); - assert!(updates < 10, "the average never caught up"); - } - assert_eq!(updates, 3); - let pool = market.pool_state(); - assert_eq!(pool.average_price, 11_080_000_000); - assert_eq!(pool.last_oracle_price, new_price as u64); -} - -#[test] -fn test_single_update_moves_average_by_elapsed_fraction() { - let mut market = Market::default_market(); - let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); - let created_at = market.pool_state().average_price_timestamp; - - // The first update after the oracle moves to $115 credits the four - // minutes since creation to $100, the price seen at creation, so the - // average stays at $100 and $115 is recorded for the next read. - market.pass_seconds(240); - market.set_price(dollars(115)); - market.update_price_average(&keeper).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, dollars(100) as u64); - assert_eq!(pool.last_oracle_price, dollars(115) as u64); - assert_eq!(pool.average_price_timestamp, created_at + 240); - - // Four more minutes at $115 are 240 of the 600-second window, so the next - // update moves the average 240/600 of the way from $100 to $115: to $106. - market.pass_seconds(240); - market.set_price(dollars(115)); - market.update_price_average(&keeper).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, dollars(106) as u64); - assert_eq!(pool.average_price_timestamp, created_at + 480); - - // Fifteen minutes is more than a full window, so the next update replaces - // the average with $115, the price at the previous read, and records the - // fall to $97. One more update credits $97 for a full window. - market.pass_seconds(900); - market.set_price(dollars(97)); - market.update_price_average(&keeper).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, dollars(115) as u64); - assert_eq!(pool.last_oracle_price, dollars(97) as u64); - market.pass_seconds(900); - market.set_price(dollars(97)); - market.update_price_average(&keeper).unwrap(); - assert_eq!(market.pool_state().average_price, dollars(97) as u64); -} - -/// A pool left idle for more than a window cannot have its average set by one -/// read of a manipulated price. The read only records the price; the interval -/// before it is credited to the price seen at the read before. Once a read of -/// the real price replaces it, the manipulated price has moved the average -/// only by the seconds between the two reads. -#[test] -fn test_one_manipulated_read_after_idle_does_not_move_average() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - let attacker = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); - - // Fifteen idle minutes, then the oracle is pushed to $160 and the - // attacker calls `update_price_average`. The average stays at $100. - market.pass_seconds(900); - market.set_price(dollars(160)); - market.update_price_average(&attacker).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, dollars(100) as u64); - assert_eq!(pool.last_oracle_price, dollars(160) as u64); - - // Six seconds later the oracle is back at $100 and is read again. The six - // seconds are credited to $160: the average moves 6/600 of the $60 gap, - // to $100.60, and $100 replaces $160 as the latest observation. - market.pass_seconds(6); - market.set_price(dollars(100)); - market.update_price_average(&attacker).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, 10_060_000_000); - assert_eq!(pool.last_oracle_price, dollars(100) as u64); - - // An open at $160 is still refused. - market.set_price(dollars(160)); - assert_fails_with( - market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), - PerpError::PriceOutsideBand, - ); + assert!(Market::try_new(dollars(100), parameters).is_err()); } diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 80b6e1eed..d878f5d8d 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,63 +1,5 @@ # Changelog -## 2026-10-01 - -Replace the leverage cap with an initial margin. `max_leverage` on -`PoolParameters` and `Pool` is now `initial_margin_bps`, the net collateral a -position must post to open, in basis points of its size (1,000 is 10x). -`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= -10_000`, refusing an initial margin at or below the maintenance margin with the -new `InitialMarginNotAboveMaintenance` and one above 10,000 with -`InvalidParameter`; `MAX_LEVERAGE_CEILING` is removed. `open_position` checks -`net_collateral * 10_000 >= size * initial_margin_bps` and fails with -`InitialMarginNotMet`, which takes `LeverageTooHigh`'s place and its error code -(6004). Its separate check that a new position starts above the maintenance -margin is removed, because the initial margin implies it; `PositionNotHealthy` -remains for `close_position`. - -Add a price band around a program-maintained average price. A fresh, confident -oracle print could still be wrong, and every handler traded at it. The pool now -keeps `average_price`, a time-weighted moving average of the oracle price, -`last_oracle_price`, the price at the most recent oracle read, and -`average_price_timestamp`. `initialize_pool` seeds the average and -`last_oracle_price` from the oracle. Every handler that reads the oracle credits -the seconds since the previous read to the price that read saw, -`average += (last_oracle_price - average) * min(elapsed, -PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new -constant at 600 seconds, and then records the price it read as -`last_oracle_price`. The price read now only counts from now, so a pool left -idle for a window or more cannot have its average set by one read of a -manipulated price: that price moves the average only if the oracle still shows -it at a later read, weighted by the seconds between the two reads. -`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse -a price outside `|price - average_price| * 10_000 <= average_price * -max_price_deviation_bps` with the new `PriceOutsideBand`, checked against the -stored average before anything is folded in. `liquidate_position` folds and -records without the check. The new permissionless `update_price_average` -handler folds and records too, also without the check, so keepers calling it -repeatedly as time passes can walk the average to a genuine move. `max_price_deviation_bps` is a new -`PoolParameters` field, which `initialize_pool` requires to be above zero and -below 10,000 with the new `InvalidPriceDeviation`. `shared.rs` has -`refresh_price_and_funding_within_band` for the four band-checked handlers -beside `refresh_price_and_funding` for the other two. The `errors` module is -public so the tests can match `PerpError` codes. - -Tested by `test_open_rejects_position_below_initial_margin` (formerly -`test_open_rejects_excess_leverage`, now checking both sides of the boundary), -`test_initialize_pool_rejects_initial_margin_at_or_below_maintenance`, -`test_initialize_pool_rejects_price_deviation_outside_range`, -`test_open_rejected_when_oracle_jumps_outside_band`, -`test_close_rejected_when_oracle_jumps_outside_band`, -`test_liquidity_changes_rejected_when_oracle_jumps_outside_band`, -`test_liquidation_runs_outside_band`, -`test_price_average_catches_up_after_genuine_move`, -`test_single_update_moves_average_by_elapsed_fraction` and -`test_one_manipulated_read_after_idle_does_not_move_average`. The default test market -uses a 1,000 basis point initial margin and a 2,000 basis point band; -`test_profit_capped_at_reserved_notional` triples the price, far outside the -band, so it now calls `update_price_average` to record the new price, lets a -full window pass, and calls it again before closing. - ## 2026-09-30 Remove `set_funding_rate`. The pool's authority could change the funding rate at diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index 2551e37dc..f048883b9 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -29,7 +29,7 @@ All arithmetic is integer `u128` with `checked_*` operations, multiplying before ### Long and short, leverage, collateral -A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size, and the pool's [initial margin](https://www.investopedia.com/terms/i/initialmargin.asp) caps their [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power): `open_position` requires the collateral left after the open fee to be at least `initial_margin_bps` of the size, checked as `net_collateral * 10_000 >= size * initial_margin_bps`, and fails with `InitialMarginNotMet` otherwise. An initial margin of 1,000 basis points (10%) allows at most 10× leverage. The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: +A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size up to the pool's maximum [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power). The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: ``` long profit/loss = size * (price - entry_price) / entry_price @@ -54,25 +54,10 @@ Funding runs on the wall clock rather than the slot count, so what a position co A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless: anyone can crank it and earn the liquidation fee. -`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= 10_000` and refuses anything else with `InitialMarginNotAboveMaintenance` (or `InvalidParameter` above 10,000). Every position therefore opens with more margin than it is liquidated at, so none can be liquidated in the slot it opened. - ### Oracle The mark price comes from an oracle feed. This example validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. -### Price band - -A single oracle print can be wrong while still being fresh, positive and confident: a publisher fault, or a thin market moved for a few seconds. To stop anyone trading against such a print, the pool keeps its own time-weighted moving average of the oracle price, `Pool.average_price`, and refuses prices too far from it. - -- `initialize_pool` reads the oracle and seeds both `average_price` and `last_oracle_price` with its price, stamping `average_price_timestamp` with the Clock's `unix_timestamp`. -- Every handler that reads the oracle credits the seconds since the previous read to the price that read saw, `last_oracle_price`, on the assumption that it held throughout: `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`. It then records the price it read as the new `last_oracle_price`. The window is 600 seconds, so a price seen at two reads six seconds apart moves the average by 1% of its gap from the average, and an interval of ten minutes or more replaces the average with the price seen at its start. -- The price read now only starts counting from now. A manipulated price moves the average only if the oracle still shows it at a later read, and only by the seconds between the two reads; a read of the real price in between replaces it. A pool left idle for longer than the window therefore cannot have its average set by a single read. -- `open_position`, `close_position`, `add_liquidity` and `remove_liquidity` first check the price against the stored average, before anything is folded in: `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. A price outside that band fails with `PriceOutsideBand`, and the pool is left unchanged. -- `liquidate_position` folds and records without the band check. A genuine crash is when positions go underwater, so liquidation keeps working through one. -- `update_price_average()` is permissionless: any signer passes the pool and its oracle feed, and the handler reads and validates the oracle with the same checks, accrues funding, folds the elapsed interval in and records the price, with no band check. After a genuine move takes the oracle outside the band, keepers call it repeatedly as time passes: the first call records the new price, and each later call credits the time since the previous one to it, until the average is close enough to the price for trading to resume. - -`max_price_deviation_bps` is fixed by `initialize_pool`, which refuses zero (every move would be refused) and 10,000 or more (a fall could never be refused, since prices are positive) with `InvalidPriceDeviation`. - ### Fees and slippage Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional and accrue to the program. Every state-changing handler takes a `minimum_*` / acceptable-price bound (protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill) and reverts if the bound is breached. Pass `0` to opt out. @@ -89,7 +74,7 @@ Open and close fees are charged in [basis points](https://www.investopedia.com/t - **Bob** (Short trader): He thinks NVDA will fall and wants to profit from the downside. - **Dave** (Liquidator): Runs a bot that closes under-margined positions to earn the liquidation fee. -Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). The pool is configured with a 10% initial margin (10× leverage), 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, a 1% maximum oracle confidence band, and a 20% price band around its average price. +Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). The pool is configured with 10× max leverage, 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, and a 1% maximum oracle confidence band. --- @@ -97,11 +82,9 @@ Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). T **Instruction:** `initialize_pool(parameters)` -The handler validates the parameters, then reads the oracle once to seed the pool's average price at $100. - **Accounts created:** -- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, average oracle price, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA +- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA - `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA, seeds `["vault", pool]`: all USDC, both provider liquidity and trader collateral; `pool` is its owner - `lp_mint` PDA, seeds `["lp_mint", pool]`: the share [mint](https://solana.com/docs/terminology#mint-account); `pool` is the mint authority @@ -154,7 +137,7 @@ While both are open, **funding** accrues to the pool from the heavier side; it i **Instruction:** `close_position(minimum_payout)` -$116 is 16% above the pool's $100 average price, inside the 20% band, so the close goes through. Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reserve cap), minus the $5 close fee. +Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reserve cap), minus the $5 close fee. **Accounts modified:** @@ -162,8 +145,6 @@ $116 is 16% above the pool's $100 average price, inside the 20% band, so the clo - `Pool.reserved_liquidity`: −$5,000 (reserve released) - `Pool.total_collateral`: −$995 - `Pool.program_fees`: +$5 -- `Pool.average_price`: credits the time since the last read to $100, the price that read saw, so it stays at $100 -- `Pool.last_oracle_price`: $100 → $116, which the next read credits for the time in between - long open-interest accumulators: −= this position - `custody_vault` → `alice_usdc`: pays out $1,790 (net collateral + profit − close fee) - `Position` (Alice): closed; rent returned to Alice @@ -214,7 +195,7 @@ The genuinely hard part of a perpetual-futures venue is keeping it solvent and p - **Account-local safety**: "every favorable action refreshes the account's full active portfolio first; … stale … legs fail closed." Here, every position and liquidity action reads a fresh oracle (stale or wide-confidence prices are rejected) and recomputes pool exposure before any payout. - **Bounded progress**: "no public instruction needs to evaluate the whole market." Here, assets-under-management comes from running per-side accumulators, and liquidation acts on one position at a time, so no handler's cost grows with the number of open positions. -What production pool-perps (`solana-labs/perpetuals`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, and valuing positions at the oracle's EMA rather than its spot price. This example keeps its own average only to decide when to refuse trading, and values positions at the spot price. +What production pool-perps (`solana-labs/perpetuals`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, and using the oracle's EMA for a less manipulable mark. --- @@ -231,18 +212,7 @@ This is a teaching example, not an audited exchange. Notably: ## Testing -The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover: - -- liquidity round-trips, and share inflation through a provider's own trades -- opening and closing longs and shorts in profit and loss -- the initial margin on both sides of its boundary, and slippage rejection -- stale-price, pre-restart-price, and wide-confidence rejection -- funding accrual, the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots -- the price band: opens, closes, deposits and withdrawals refused when the oracle jumps outside it (`test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band`, `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`), liquidation running outside it (`test_liquidation_runs_outside_band`), the exact average after each `update_price_average` (`test_single_update_moves_average_by_elapsed_fraction`), repeated updates walking the average to a genuine move until trading resumes (`test_price_average_catches_up_after_genuine_move`), and one manipulated read after an idle window leaving the average where it was (`test_one_manipulated_read_after_idle_does_not_move_average`) -- liquidation, and the refusal to liquidate a healthy position -- reserved-liquidity behaviour: profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity -- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin and a price band outside its range -- fee collection +The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price, pre-restart-price, and wide-confidence rejection, funding accrual, funding-rate retuning (including that it settles elapsed seconds at the old rate, and that only the authority may call it), funding that follows seconds rather than slots, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. ```bash anchor build diff --git a/finance/perpetual-futures/anchor/TERMINOLOGY.md b/finance/perpetual-futures/anchor/TERMINOLOGY.md index 55493ce89..26c7bd5e0 100644 --- a/finance/perpetual-futures/anchor/TERMINOLOGY.md +++ b/finance/perpetual-futures/anchor/TERMINOLOGY.md @@ -2,47 +2,36 @@ Terms used in this example, in the sense they carry here. -- **Perpetual future (perp)**: a leveraged derivative position with no expiry +- **Perpetual future (perp)** — a leveraged derivative position with no expiry and no settlement date. Profit and loss is paid in the collateral token as the oracle price moves. -- **Long / short**: a long profits when the price rises, a short when it falls. +- **Long / short** — a long profits when the price rises, a short when it falls. Each is the opposite side of the pool's exposure. -- **Collateral**: the token a trader posts to back a position, and the token +- **Collateral** — the token a trader posts to back a position, and the token liquidity providers deposit. One pool uses one collateral token. -- **Notional size**: the position's exposure in collateral units. Profit and +- **Notional size** — the position's exposure in collateral units. Profit and loss scales with the notional, not with the collateral posted. -- **Leverage**: notional size divided by collateral. A pool caps it through its - initial margin: 1,000 basis points (10%) allows at most 10x. -- **Initial margin**: the net collateral, as a fraction of notional size, a - position must post to open (`initial_margin_bps`). Always above the - maintenance margin, so no position opens already liquidatable. -- **Equity**: a position's current worth: net collateral plus unrealized profit +- **Leverage** — notional size divided by collateral. A pool caps it at + `max_leverage`. +- **Equity** — a position's current worth: net collateral plus unrealized profit and loss, minus accrued funding. When equity falls to the maintenance margin, the position is liquidatable. -- **Maintenance margin**: the minimum equity, as a fraction of notional size, +- **Maintenance margin** — the minimum equity, as a fraction of notional size, a position must keep to avoid liquidation. -- **Liquidation**: closing an under-margined position. Permissionless here: any +- **Liquidation** — closing an under-margined position. Permissionless here: any caller can trigger it and earns the liquidation fee. -- **Funding**: a periodic payment that anchors the pool's risk. The heavier +- **Funding** — a periodic payment that anchors the pool's risk. The heavier side of open interest pays funding to the pool over time. -- **Open interest**: the total notional size currently open on a side. -- **Liquidity provider**: a depositor who funds the pool and is the counterparty +- **Open interest** — the total notional size currently open on a side. +- **Liquidity provider** — a depositor who funds the pool and is the counterparty to every trade, earning fees in exchange for taking the other side of trader profit and loss. -- **Assets-under-management**: the marked value of liquidity-provider holdings: +- **Assets-under-management** — the marked value of liquidity-provider holdings: pool liquidity minus the aggregate unrealized profit traders are owed. -- **Liquidity-provider share**: a token representing a pro-rata claim on +- **Liquidity-provider share** — a token representing a pro-rata claim on assets-under-management. -- **Oracle feed**: the account the pool reads its price from. This example uses +- **Oracle feed** — the account the pool reads its price from. This example uses a mock oracle price feed; production points at a real one, such as a Pyth price feed. -- **Mark price**: the price positions are valued at. Here it is the oracle +- **Mark price** — the price positions are valued at. Here it is the oracle price directly, with no separate mark/index distinction. -- **Average price**: the pool's time-weighted moving average of the oracle - price (`average_price`), which follows the last ten minutes of prices. Each - oracle read credits the seconds since the previous read to the price that - read saw (`last_oracle_price`), so a price counts only from the read that - first sees it. Positions are never valued at it. -- **Price band**: the range around the average price, `max_price_deviation_bps` - wide on each side, outside which the pool refuses to open or close positions - or move liquidity. Liquidation is not refused. diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs index 706426f43..07bedc4fc 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs @@ -33,18 +33,10 @@ pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// lowers over time, so the window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; -/// How many seconds of oracle prices the pool's `average_price` follows. Each -/// fold moves the average toward the price seen at the previous read by -/// `elapsed / window` of the gap between them, and an interval of a full window -/// or more replaces the average with that price. Ten minutes is long enough -/// that a price seen at two reads six seconds apart, about as long as a faulty -/// or manipulated oracle print lasts, moves the average by one percent of its -/// jump, and short enough that a genuine move is back inside the band within -/// minutes of repeated reads. Counted on the Clock's `unix_timestamp`, -/// like funding: it is a span of wall-clock time, and the second or two of -/// leader drift changes a fold's weight by well under one percent. -#[constant] -pub const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; +/// Upper bound on the per-pool `max_leverage` parameter, so a pool cannot be +/// configured with an absurd leverage that makes every position instantly +/// liquidatable on the smallest price move. +pub const MAX_LEVERAGE_CEILING: u16 = 100; /// Upper bound on the per-pool `funding_rate_per_second` parameter, in /// `FUNDING_PRECISION` units: 277 billionths of a position's size per second, diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs index 5a18aa401..3f33f0c2c 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs @@ -14,8 +14,8 @@ pub enum PerpError { #[msg("Arithmetic overflow")] MathOverflow, - #[msg("Position is too large for its collateral: net collateral is below the pool's initial margin")] - InitialMarginNotMet, + #[msg("Requested leverage exceeds the pool maximum")] + LeverageTooHigh, #[msg("Pool parameter is outside the allowed range")] InvalidParameter, @@ -58,13 +58,4 @@ pub enum PerpError { #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, - - #[msg("Initial margin is at or below the maintenance margin: positions could open already liquidatable")] - InitialMarginNotAboveMaintenance, - - #[msg("Maximum price deviation is outside the allowed range: it must be above zero and below 10,000 basis points")] - InvalidPriceDeviation, - - #[msg("Oracle price is too far from the pool's average price: trading pauses until the average catches up")] - PriceOutsideBand, } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs index 95955d80f..7cbd26bee 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; +use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding}; use crate::state::Pool; pub fn handle_add_liquidity( @@ -19,7 +19,7 @@ pub fn handle_add_liquidity( require!(amount > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply(); let shares: u64 = if lp_supply == 0 && pool.liquidity == 0 { diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs index 5440a8b73..bbfebd9bb 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs @@ -6,9 +6,7 @@ use anchor_spl::{ use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{ - basis_points_of, refresh_price_and_funding_within_band, settle_position, -}; +use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, settle_position}; use crate::state::{Pool, Position}; pub fn handle_close_position( @@ -16,7 +14,7 @@ pub fn handle_close_position( minimum_payout: u64, ) -> Result<()> { let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; let position = &context.accounts.position; let position_size = position.size; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs index 2c096339c..df3660d33 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -7,10 +7,10 @@ use anchor_spl::{ }; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, POOL_SEED, VAULT_SEED, + BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING, + POOL_SEED, VAULT_SEED, }; use crate::errors::PerpError; -use crate::state::oracle::read_oracle_price; use crate::state::Pool; /// Trading parameters set once at pool creation. None of them can be changed @@ -27,22 +27,12 @@ pub struct PoolParameters { pub open_fee_bps: u16, pub close_fee_bps: u16, - - /// Net collateral a position must post to open, in basis points of its - /// notional size. Must be above `maintenance_margin_bps` and at most - /// 10_000 (no leverage). - pub initial_margin_bps: u16, - + pub max_leverage: u16, pub maintenance_margin_bps: u16, pub liquidation_fee_bps: u16, /// Maximum oracle confidence band tolerated, in basis points of the price. pub max_confidence_bps: u16, - - /// Widest gap, in basis points of the pool's average price, between the - /// oracle price and that average at which positions may still open or - /// close and liquidity may still move. - pub max_price_deviation_bps: u16, } pub fn handle_initialize_pool( @@ -50,6 +40,10 @@ pub fn handle_initialize_pool( parameters: PoolParameters, ) -> Result<()> { let denominator = BASIS_POINTS_DENOMINATOR as u16; + require!( + parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING, + PerpError::InvalidParameter + ); // The rate never changes after this, so bounding it here bounds it for the // life of the pool. require!( @@ -83,40 +77,12 @@ pub fn handle_initialize_pool( parameters.maintenance_margin_bps > parameters.close_fee_bps, PerpError::InvalidParameter ); - // A position must open with more margin than it is liquidated at, or it - // could be liquidated in the same slot it opened. At most 100% of - // notional: more than that would demand collateral above the position's - // size. - require!( - parameters.initial_margin_bps > parameters.maintenance_margin_bps, - PerpError::InitialMarginNotAboveMaintenance - ); - require!( - parameters.initial_margin_bps <= denominator, - PerpError::InvalidParameter - ); // Zero would reject every real feed (which always reports some uncertainty); // above 100% is meaningless. Anything in between is a valid risk choice. require!( parameters.max_confidence_bps > 0 && parameters.max_confidence_bps < denominator, PerpError::InvalidParameter ); - // Zero would refuse every price move, however small. At 100% or more the - // band could never refuse a fall, since the oracle price is always - // positive. - require!( - parameters.max_price_deviation_bps > 0 && parameters.max_price_deviation_bps < denominator, - PerpError::InvalidPriceDeviation - ); - - // Seed the average with a validated oracle price, so the band is in force - // from the first trade. - let initial_price = read_oracle_price( - &context.accounts.oracle_feed, - parameters.oracle_scale, - parameters.max_confidence_bps, - )?; - let current_timestamp = Clock::get()?.unix_timestamp; let pool = &mut context.accounts.pool; pool.authority = *context.accounts.authority.address(); @@ -134,18 +100,14 @@ pub fn handle_initialize_pool( pool.long_size_scaled = 0; pool.short_size_scaled = 0; pool.cumulative_funding = 0; - pool.last_funding_timestamp = current_timestamp; - pool.average_price = initial_price; - pool.last_oracle_price = initial_price; - pool.average_price_timestamp = current_timestamp; + pool.last_funding_timestamp = Clock::get()?.unix_timestamp; pool.funding_rate_per_second = parameters.funding_rate_per_second; pool.open_fee_bps = parameters.open_fee_bps; pool.close_fee_bps = parameters.close_fee_bps; - pool.initial_margin_bps = parameters.initial_margin_bps; + pool.max_leverage = parameters.max_leverage; pool.maintenance_margin_bps = parameters.maintenance_margin_bps; pool.liquidation_fee_bps = parameters.liquidation_fee_bps; pool.max_confidence_bps = parameters.max_confidence_bps; - pool.max_price_deviation_bps = parameters.max_price_deviation_bps; pool.bump = context.bumps.pool; Ok(()) @@ -168,9 +130,8 @@ pub struct InitializePoolAccountConstraints { pub collateral_mint: Box>, /// CHECK: The oracle feed account. Its key is stored on the pool and every - /// read, including the one here that seeds the average price, validates - /// the layout, scale, and freshness; it is never trusted by type. Swap for - /// a real Pyth price feed in production. + /// read validates the layout, scale, and freshness; it is never trusted by + /// type. Swap for a real Pyth price feed in production. pub oracle_feed: UncheckedAccount, /// Liquidity-provider share mint. The pool account is its mint authority diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs index 33c621dee..88337e1df 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs @@ -6,7 +6,6 @@ pub mod liquidate_position; pub mod open_position; pub mod remove_liquidity; pub mod shared; -pub mod update_price_average; pub use add_liquidity::*; pub use close_position::*; @@ -15,4 +14,3 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; -pub use update_price_average::*; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs index 8cf66d3ab..e820f032f 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs @@ -4,11 +4,9 @@ use anchor_spl::{ token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, }; -use crate::constants::{BASIS_POINTS_DENOMINATOR, POOL_SEED, POSITION_SEED, VAULT_SEED}; +use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{ - basis_points_of, refresh_price_and_funding_within_band, scale_size, -}; +use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, scale_size}; use crate::state::{Pool, Position, Side}; pub fn handle_open_position( @@ -21,7 +19,7 @@ pub fn handle_open_position( require!(collateral_amount > 0 && size > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; // Slippage: a long must not fill above the caller's limit, a short not // below it. `0` opts out. @@ -34,28 +32,21 @@ pub fn handle_open_position( } // The open fee is taken out of the posted collateral; the rest backs the - // position, and the initial margin is measured against this net collateral. + // position. Leverage and margin are measured against this net collateral. let open_fee = basis_points_of(size, pool.open_fee_bps)?; let net_collateral = collateral_amount .checked_sub(open_fee) .ok_or(PerpError::InsufficientCollateral)?; require!(net_collateral > 0, PerpError::ZeroAmount); - // Initial margin: net collateral must be at least `initial_margin_bps` of - // the notional size, compared as `net_collateral * 10_000 >= size * bps` - // so nothing is rounded. `initialize_pool` keeps the initial margin above - // the maintenance margin, so a position that passes this check opens with - // equity above the liquidation threshold. - let collateral_scaled = (net_collateral as u128) - .checked_mul(BASIS_POINTS_DENOMINATOR as u128) - .ok_or(PerpError::MathOverflow)?; - let required_scaled = (size as u128) - .checked_mul(pool.initial_margin_bps as u128) + let max_notional = (net_collateral as u128) + .checked_mul(pool.max_leverage as u128) .ok_or(PerpError::MathOverflow)?; - require!( - collateral_scaled >= required_scaled, - PerpError::InitialMarginNotMet - ); + require!(size as u128 <= max_notional, PerpError::LeverageTooHigh); + + // Refuse a position that would open already inside the liquidation band. + let maintenance = basis_points_of(size, pool.maintenance_margin_bps)?; + require!(net_collateral > maintenance, PerpError::PositionNotHealthy); // Reserve liquidity to cover this position's maximum recoverable profit // (its notional `size`). The reserve must be backed by liquidity-provider diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs index c3cf66c56..9d3227ca1 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; +use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding}; use crate::state::Pool; pub fn handle_remove_liquidity( @@ -19,7 +19,7 @@ pub fn handle_remove_liquidity( require!(shares > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply(); let aum = liquidity_provider_aum(pool, price)?; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs index 38d2ce17c..4e47e3235 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs @@ -1,8 +1,6 @@ use anchor_lang::prelude::*; -use crate::constants::{ - BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, PRICE_AVERAGE_WINDOW_SECONDS, SIZE_PRECISION, -}; +use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; use crate::errors::PerpError; use crate::state::{Pool, Position, Side}; @@ -218,102 +216,16 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { .map_err(|_| PerpError::MathOverflow.into()) } -/// Fold the elapsed interval into the pool's `average_price`, then record -/// `price` as the latest observation. -/// -/// The interval since the last fold is credited to the price observed at -/// that fold, `last_oracle_price`, on the assumption that it held throughout: -/// -/// `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS` -/// -/// The price read now only starts counting from now, so it moves the average -/// only if it is still the oracle's price at a later read, weighted by the -/// seconds between the two reads; a read of a different price in between -/// replaces it. A pool left idle for a window or more therefore cannot have -/// its average set by one read. As with funding, a timestamp at or before the -/// stored one is treated as no time elapsed: the average and the stored stamp -/// stay where they are, and only `last_oracle_price` is updated. -pub fn fold_price_into_average(pool: &mut Pool, price: u64, current_timestamp: i64) -> Result<()> { - if current_timestamp <= pool.average_price_timestamp { - pool.last_oracle_price = price; - return Ok(()); - } - let elapsed = current_timestamp - .checked_sub(pool.average_price_timestamp) - .ok_or(PerpError::MathOverflow)?; - let weight = elapsed.min(PRICE_AVERAGE_WINDOW_SECONDS); - - let average = pool.average_price as i128; - // Multiply before dividing; the gap is signed, so the average moves down - // as readily as up. - let movement = (pool.last_oracle_price as i128) - .checked_sub(average) - .ok_or(PerpError::MathOverflow)? - .checked_mul(weight as i128) - .ok_or(PerpError::MathOverflow)? - .checked_div(PRICE_AVERAGE_WINDOW_SECONDS as i128) - .ok_or(PerpError::MathOverflow)?; - pool.average_price = average - .checked_add(movement) - .ok_or(PerpError::MathOverflow)? - .try_into() - .map_err(|_| PerpError::MathOverflow)?; - pool.last_oracle_price = price; - pool.average_price_timestamp = current_timestamp; - Ok(()) -} - -/// Refuse an oracle `price` more than `max_price_deviation_bps` away from the -/// pool's stored `average_price`: -/// `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. -pub fn require_price_within_band(pool: &Pool, price: u64) -> Result<()> { - let deviation_scaled = (price.abs_diff(pool.average_price) as u128) - .checked_mul(BASIS_POINTS_DENOMINATOR as u128) - .ok_or(PerpError::MathOverflow)?; - let band_scaled = (pool.average_price as u128) - .checked_mul(pool.max_price_deviation_bps as u128) - .ok_or(PerpError::MathOverflow)?; - require!(deviation_scaled <= band_scaled, PerpError::PriceOutsideBand); - Ok(()) -} - -/// The preamble `liquidate_position` and `update_price_average` run: read a -/// validated oracle price, bring the pool's funding index up to the current -/// time, and fold the interval since the previous read into the pool's average -/// (see `fold_price_into_average`), so the settlement that follows uses fresh -/// numbers. Centralized so no handler can settle a position -/// against a stale funding index. -/// -/// No band check: liquidation has to keep working through a genuine price -/// move, because that is when positions go underwater, and -/// `update_price_average` is how the average catches up with one. +/// The preamble every price-sensitive handler runs: read a validated oracle +/// price, then bring the pool's funding index up to the current time, so the +/// settlement that follows uses fresh numbers for both. Centralized so no +/// handler can settle a position against a stale funding index. pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountView) -> Result { - let price = read_pool_oracle_price(pool, oracle_feed)?; - apply_price_and_funding(pool, price)?; - Ok(price) -} - -/// The preamble for every handler that opens or closes a position or moves -/// liquidity: the same as `refresh_price_and_funding`, but first refuses a -/// price outside the band around the stored average, before anything is -/// folded in or the price is recorded. A single oracle print far from the -/// average therefore cannot open, close, deposit, or withdraw at that price. -pub fn refresh_price_and_funding_within_band( - pool: &mut Pool, - oracle_feed: &AccountView, -) -> Result { - let price = read_pool_oracle_price(pool, oracle_feed)?; - require_price_within_band(pool, price)?; - apply_price_and_funding(pool, price)?; + let price = crate::state::oracle::read_oracle_price( + oracle_feed, + pool.oracle_scale, + pool.max_confidence_bps, + )?; + accrue_funding(pool, Clock::get()?.unix_timestamp)?; Ok(price) } - -fn read_pool_oracle_price(pool: &Pool, oracle_feed: &AccountView) -> Result { - crate::state::oracle::read_oracle_price(oracle_feed, pool.oracle_scale, pool.max_confidence_bps) -} - -fn apply_price_and_funding(pool: &mut Pool, price: u64) -> Result<()> { - let current_timestamp = Clock::get()?.unix_timestamp; - accrue_funding(pool, current_timestamp)?; - fold_price_into_average(pool, price, current_timestamp) -} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs deleted file mode 100644 index 9da9fa577..000000000 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs +++ /dev/null @@ -1,30 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::constants::POOL_SEED; -use crate::instructions::shared::refresh_price_and_funding; -use crate::state::Pool; - -pub fn handle_update_price_average( - context: &mut Context, -) -> Result<()> { - refresh_price_and_funding(&mut context.accounts.pool, &context.accounts.oracle_feed)?; - Ok(()) -} - -#[derive(Accounts)] -pub struct UpdatePriceAverageAccountConstraints { - /// Anyone may update the average: the result depends only on the oracle - /// price and the clock, never on who calls. - pub caller: Signer, - - #[account( - mut, - seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], - bump = pool.bump, - )] - pub pool: Box>, - - /// CHECK: validated by the `address = pool.oracle_feed` constraint below. - #[account(address = pool.oracle_feed)] - pub oracle_feed: UncheckedAccount, -} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs index 723f03a81..6329dc855 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs @@ -1,11 +1,10 @@ use anchor_lang::prelude::*; mod constants; +mod errors; mod last_restart; // Public so the LiteSVM integration tests can build instruction arguments -// (`PoolParameters`, `Side`) against the program's own types, and match -// failures against `PerpError` codes. -pub mod errors; +// (`PoolParameters`, `Side`) against the program's own types. pub mod instructions; pub mod state; @@ -79,18 +78,6 @@ pub mod perpetual_futures { instructions::handle_liquidate_position(context) } - /// Read the oracle, credit the seconds since the previous read to the - /// price that read saw, record the current price for the next read, and - /// accrue funding up to now. Permissionless: after a genuine price move - /// takes the oracle outside the pool's band, anyone can call this - /// repeatedly as time passes to walk the average toward the new price until - /// trading resumes. - pub fn update_price_average( - context: &mut Context, - ) -> Result<()> { - instructions::handle_update_price_average(context) - } - /// The pool operator sweeps the accumulated program fees from the vault. pub fn collect_fees(context: &mut Context) -> Result<()> { instructions::handle_collect_fees(context) diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs index ad7993d5a..a8df9b5c0 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs @@ -71,23 +71,6 @@ pub struct Pool { /// the cluster's slot time. pub last_funding_timestamp: i64, - /// Time-weighted moving average of the oracle price, in the pool's - /// `oracle_scale` fixed point. Seeded with the oracle price when the pool is - /// created. Every handler that reads the oracle credits the seconds since - /// the previous read to `last_oracle_price`, the price that read saw. - /// Trading and liquidity handlers refuse an oracle price more than - /// `max_price_deviation_bps` away from it, so a sudden jump pauses them - /// until the average catches up. - pub average_price: u64, - - /// The oracle price at the most recent read, in `oracle_scale` fixed point. - /// The next read folds it into `average_price` for the seconds in between. - pub last_oracle_price: u64, - - /// The Clock's `unix_timestamp` of the most recent fold into - /// `average_price`. - pub average_price_timestamp: i64, - /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. pub funding_rate_per_second: u64, @@ -97,13 +80,11 @@ pub struct Pool { pub close_fee_bps: u16, - /// Net collateral a position must post to open, in basis points of its - /// notional size: 1_000 allows at most 10x leverage. Always above - /// `maintenance_margin_bps`, so no position opens already liquidatable. - pub initial_margin_bps: u16, + /// Highest leverage a position may open at (`size <= collateral * max`). + pub max_leverage: u16, - /// Equity threshold, in basis points of notional, at or below which a - /// position is liquidatable. + /// Equity threshold, in basis points of notional, below which a position is + /// liquidatable. pub maintenance_margin_bps: u16, /// Reward paid to a liquidator, in basis points of the liquidated notional. @@ -113,10 +94,6 @@ pub struct Pool { /// pool will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, - /// Widest gap the pool trades across between the oracle price and - /// `average_price`, in basis points of `average_price`. - pub max_price_deviation_bps: u16, - /// Bump of this account's own address. The pool owns the custody vault /// and is the LP mint's authority, so it signs vault transfers and /// mint/burn CPIs with `[POOL_SEED, collateral_mint, oracle_feed, bump]`; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs index 791fbb1bd..9cfc05847 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -4,11 +4,7 @@ use { InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, - perpetual_futures::{ - errors::PerpError, - instructions::initialize_pool::PoolParameters, - state::{Pool, Position, Side}, - }, + perpetual_futures::{instructions::initialize_pool::PoolParameters, state::Pool, state::Side}, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, get_token_account_balance, mint_tokens_to_token_account, @@ -19,9 +15,6 @@ use { // Matches `MAX_FUNDING_RATE_PER_SECOND` in the program's constants: the // steepest funding rate `initialize_pool` accepts. const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; -// Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds -// replaces the pool's average price with the oracle price. -const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; // Ten years, in seconds. const TEN_YEARS: i64 = 315_360_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 @@ -57,37 +50,6 @@ fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } -/// The parameters every test market uses unless a test overrides one: 0.1% -/// open and close fees, a 10% initial margin (10x leverage), a 5% maintenance -/// margin, a 1% liquidation fee, a 1% maximum confidence band, and a 20% price -/// band around the pool's average price. -fn default_parameters(funding_rate_per_second: u64) -> PoolParameters { - PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - open_fee_bps: 10, - close_fee_bps: 10, - initial_margin_bps: 1_000, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, - max_price_deviation_bps: 2_000, - } -} - -/// Assert that `result` failed with the program's `expected` error. Anchor -/// reports a program error as `Custom(6000 + the variant's index)`. -fn assert_fails_with(result: Result, expected: PerpError) { - let code = expected as u32 + 6000; - let Err(error) = result else { - panic!("the transaction should have failed with error code {code}"); - }; - assert!( - error.contains(&format!("Custom({code})")), - "expected error code {code}, got: {error}" - ); -} - /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -105,14 +67,23 @@ impl Market { /// funding rate. The admin is both the pool operator and the oracle feed /// authority. fn new(initial_price: i128, funding_rate_per_second: u64) -> Market { - Market::try_new(initial_price, default_parameters(funding_rate_per_second)) - .expect("pool initialization should succeed") + let parameters = PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + max_leverage: 10, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + }; + Market::try_new(initial_price, parameters).expect("pool initialization should succeed") } /// Like `new`, but takes the full parameter set and surfaces an /// `initialize_pool` rejection instead of panicking, so tests can probe the /// parameter validation. - fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { + fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { let mut svm = anchor_v2_testing::svm(); svm.add_program( perpetual_futures::id(), @@ -194,7 +165,7 @@ impl Market { &[&admin], &admin.pubkey(), ) - .map_err(|error| format!("{error:?}"))?; + .map_err(|_| ())?; Ok(Market { svm, @@ -302,7 +273,7 @@ impl Market { provider_collateral: Address, amount: u64, minimum_shares_out: u64, - ) -> Result<(), String> { + ) -> Result<(), ()> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -332,7 +303,8 @@ impl Market { &[provider], &provider.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } fn remove_liquidity( @@ -341,7 +313,7 @@ impl Market { provider_collateral: Address, shares: u64, minimum_amount_out: u64, - ) -> Result<(), String> { + ) -> Result<(), ()> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -371,7 +343,8 @@ impl Market { &[provider], &provider.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } fn position_pda(&self, owner: &Address, side: Side) -> Address { @@ -394,7 +367,7 @@ impl Market { collateral_amount: u64, size: u64, acceptable_price: u64, - ) -> Result<(), String> { + ) -> Result<(), ()> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -425,7 +398,8 @@ impl Market { &[trader], &trader.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } fn close_position( @@ -434,7 +408,7 @@ impl Market { trader_collateral: Address, side: Side, minimum_payout: u64, - ) -> Result<(), String> { + ) -> Result<(), ()> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -459,7 +433,8 @@ impl Market { &[trader], &trader.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } fn liquidate( @@ -468,7 +443,7 @@ impl Market { owner: &Address, owner_collateral: Address, side: Side, - ) -> Result<(), String> { + ) -> Result<(), ()> { let position = self.position_pda(owner, side); let liquidator_collateral = derive_ata(&liquidator.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( @@ -496,10 +471,11 @@ impl Market { &[liquidator], &liquidator.pubkey(), ) - .map_err(|error| format!("{error:?}")) + .map(|_| ()) + .map_err(|_| ()) } - fn collect_fees(&mut self, authority: &Keypair) -> Result<(), String> { + fn collect_fees(&mut self, authority: &Keypair) -> Result<(), ()> { let authority_collateral = derive_ata(&authority.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -522,40 +498,8 @@ impl Market { &[authority], &authority.pubkey(), ) - .map_err(|error| format!("{error:?}")) - } - - fn update_price_average(&mut self, caller: &Keypair) -> Result<(), String> { - let instruction = Instruction::new_with_bytes( - perpetual_futures::id(), - &perpetual_futures::instruction::UpdatePriceAverage {}.data(), - perpetual_futures::accounts::UpdatePriceAverageAccountConstraints { - caller: caller.pubkey(), - pool: self.pool, - oracle_feed: self.feed, - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut self.svm, - vec![instruction], - &[caller], - &caller.pubkey(), - ) - .map_err(|error| format!("{error:?}")) - } - - /// Hold the oracle at `price` while the pool's average catches up with - /// it: one update records `price` as the latest observation, then a full - /// averaging window passes with the price republished so it is fresh, and - /// a second update credits that window to `price`. A price more than the - /// band away from the average cannot be traded at until this has run. - fn settle_average_at(&mut self, price: i128) { - let caller = self.payer.insecure_clone(); - self.update_price_average(&caller).unwrap(); - self.pass_seconds(PRICE_AVERAGE_WINDOW_SECONDS); - self.set_price(price); - self.update_price_average(&caller).unwrap(); + .map(|_| ()) + .map_err(|_| ()) } /// Deposit a large amount of liquidity so the pool can pay trader profits, @@ -577,17 +521,7 @@ fn test_initialize_pool() { assert_eq!(pool.collateral_mint, market.collateral_mint); assert_eq!(pool.oracle_feed, market.feed); assert_eq!(pool.oracle_scale, ORACLE_SCALE); - assert_eq!(pool.initial_margin_bps, 1_000); - assert_eq!(pool.max_price_deviation_bps, 2_000); - // The average starts at the oracle price the pool was created against. - assert_eq!(pool.average_price, dollars(100) as u64); - assert_eq!( - pool.average_price_timestamp, - market - .svm - .get_sysvar::() - .unix_timestamp - ); + assert_eq!(pool.max_leverage, 10); assert_eq!(pool.liquidity, 0); assert_eq!(pool.total_collateral, 0); @@ -913,53 +847,17 @@ fn test_open_rejects_zero_amounts() { } #[test] -fn test_open_rejects_position_below_initial_margin() { +fn test_open_rejects_excess_leverage() { let mut market = Market::default_market(); market.seed_liquidity(100_000 * ONE_USDC); - let (trader, trader_collateral) = market.funded_trader(2_000 * ONE_USDC); - - // The initial margin is 10% of notional. 1,000 USDC of collateral less - // the 11 USDC open fee leaves 989 USDC, short of the 1,100 USDC an 11,000 - // USDC position needs. - assert_fails_with( - market.open_position( - &trader, - trader_collateral, - Side::Long, - 1_000 * ONE_USDC, - 11_000 * ONE_USDC, - 0, - ), - PerpError::InitialMarginNotMet, - ); + let collateral = 1_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); - // A 10,000 USDC position needs 1,000 USDC net of its 10 USDC open fee. - // One minor unit short of 1,010 USDC is refused, and exactly 1,010 USDC - // opens at 10x. - let size = 10_000 * ONE_USDC; - let exact_collateral = 1_010 * ONE_USDC; - assert_fails_with( - market.open_position( - &trader, - trader_collateral, - Side::Long, - exact_collateral - 1, - size, - 0, - ), - PerpError::InitialMarginNotMet, - ); - market - .open_position( - &trader, - trader_collateral, - Side::Long, - exact_collateral, - size, - 0, - ) - .unwrap(); - assert_eq!(market.pool_state().total_collateral, size / 10); + // max_leverage is 10x; 11x must be rejected. + let size = 11_000 * ONE_USDC; + assert!(market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .is_err()); } #[test] @@ -1158,18 +1056,18 @@ fn test_funding_follows_seconds_not_slots() { #[test] fn test_initialize_pool_rejects_funding_rate_above_the_maximum() { // The rate is fixed at creation, so this is the only place it is checked. - assert_fails_with( - Market::try_new( - dollars(100), - default_parameters(MAX_FUNDING_RATE_PER_SECOND + 1), - ), - PerpError::InvalidParameter, - ); - assert!(Market::try_new( - dollars(100), - default_parameters(MAX_FUNDING_RATE_PER_SECOND) - ) - .is_ok()); + let parameters = |funding_rate_per_second| PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + max_leverage: 10, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + }; + assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND + 1)).is_err()); + assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND)).is_ok()); } /// The pool operator trading against their own pool. The lighter side of open @@ -1397,11 +1295,8 @@ fn test_profit_capped_at_reserved_notional() { .unwrap(); // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). A move this large is - // far outside the price band, so the average has to catch up before the - // position can close. + // profit is capped at the reserved notional (`size`). market.set_price(dollars(300)); - market.settle_average_at(dollars(300)); market .close_position(&trader, trader_collateral, Side::Long, 0) .unwrap(); @@ -1450,304 +1345,14 @@ fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. let parameters = PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second: 0, + open_fee_bps: 10, close_fee_bps: 600, - ..default_parameters(0) - }; - assert_fails_with( - Market::try_new(dollars(100), parameters), - PerpError::InvalidParameter, - ); -} - -#[test] -fn test_initialize_pool_rejects_initial_margin_at_or_below_maintenance() { - // An initial margin at or below the 5% maintenance margin would let a - // position open already liquidatable. - let with_initial_margin = |initial_margin_bps| PoolParameters { - initial_margin_bps, - ..default_parameters(0) - }; - assert_fails_with( - Market::try_new(dollars(100), with_initial_margin(500)), - PerpError::InitialMarginNotAboveMaintenance, - ); - assert_fails_with( - Market::try_new(dollars(100), with_initial_margin(350)), - PerpError::InitialMarginNotAboveMaintenance, - ); - - // Above 100% of notional is refused too. One basis point above the - // maintenance margin, and exactly 100%, are accepted. - assert_fails_with( - Market::try_new(dollars(100), with_initial_margin(10_001)), - PerpError::InvalidParameter, - ); - assert!(Market::try_new(dollars(100), with_initial_margin(501)).is_ok()); - assert!(Market::try_new(dollars(100), with_initial_margin(10_000)).is_ok()); -} - -#[test] -fn test_initialize_pool_rejects_price_deviation_outside_range() { - let with_deviation = |max_price_deviation_bps| PoolParameters { - max_price_deviation_bps, - ..default_parameters(0) + max_leverage: 10, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, }; - for rejected in [0, 10_000] { - assert_fails_with( - Market::try_new(dollars(100), with_deviation(rejected)), - PerpError::InvalidPriceDeviation, - ); - } - assert!(Market::try_new(dollars(100), with_deviation(1)).is_ok()); - assert!(Market::try_new(dollars(100), with_deviation(9_999)).is_ok()); -} - -/// A single oracle print far from the pool's average cannot be traded at: the -/// open is refused before the price is folded into the average. -#[test] -fn test_open_rejected_when_oracle_jumps_outside_band() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - - // The band is 20% around the $100 average: $125 and $79 are outside it. - for outside_price in [dollars(125), dollars(79)] { - market.set_price(outside_price); - // The two refused opens are otherwise byte-identical transactions. - market.svm.expire_blockhash(); - assert_fails_with( - market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), - PerpError::PriceOutsideBand, - ); - // The refused open folded nothing into the average. - assert_eq!(market.pool_state().average_price, dollars(100) as u64); - } - - // $118 is inside the band, and opens at that price. - market.set_price(dollars(118)); - market.svm.expire_blockhash(); - market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .unwrap(); - let position_account = market - .svm - .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) - .unwrap(); - let position = Position::try_deserialize(&mut position_account.data.as_slice()).unwrap(); - assert_eq!(position.entry_price, dollars(118) as u64); -} - -#[test] -fn test_close_rejected_when_oracle_jumps_outside_band() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .unwrap(); - - // A jump to $125 would pay the long $1,250, but $125 is 25% from the - // $100 average, outside the 20% band. - market.set_price(dollars(125)); - assert_fails_with( - market.close_position(&trader, trader_collateral, Side::Long, 0), - PerpError::PriceOutsideBand, - ); - - // At $115, inside the band, the close goes through and pays the 15% gain. - market.set_price(dollars(115)); - market.svm.expire_blockhash(); - market - .close_position(&trader, trader_collateral, Side::Long, 0) - .unwrap(); - let fee = size / 1_000; - let profit = size * 15 / 100; - assert_eq!( - get_token_account_balance(&market.svm, &trader_collateral).unwrap(), - collateral - fee + profit - fee - ); -} - -/// Liquidation has no band check: a genuine crash is when positions go -/// underwater, so the pool has to be able to liquidate through one. -#[test] -fn test_liquidation_runs_outside_band() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_100 * ONE_USDC; - let size = 10_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .unwrap(); - - // $75 is 25% below the $100 average, so the owner cannot close there. - market.set_price(dollars(75)); - assert_fails_with( - market.close_position(&trader, trader_collateral, Side::Long, 0), - PerpError::PriceOutsideBand, - ); - - let liquidator = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); - market - .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) - .unwrap(); - assert!(market - .svm - .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) - .is_none()); - assert_eq!(market.pool_state().long_size, 0); -} - -#[test] -fn test_liquidity_changes_rejected_when_oracle_jumps_outside_band() { - let mut market = Market::default_market(); - let (provider, provider_collateral) = market.seed_liquidity(10_000 * ONE_USDC); - let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); - let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); - - // $76 is 24% below the $100 average. - market.set_price(dollars(76)); - let (depositor, depositor_collateral) = market.funded_trader(5_000 * ONE_USDC); - assert_fails_with( - market.add_liquidity(&depositor, depositor_collateral, 5_000 * ONE_USDC, 0), - PerpError::PriceOutsideBand, - ); - assert_fails_with( - market.remove_liquidity(&provider, provider_collateral, shares, 0), - PerpError::PriceOutsideBand, - ); -} - -/// After a genuine move outside the band, anyone can walk the average toward -/// the new price with `update_price_average`, and trading resumes once the -/// price is back inside the band. -#[test] -fn test_price_average_catches_up_after_genuine_move() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); - - // NVDAx reprices from $100 to $130, 30% away from the average. - let new_price = dollars(130); - market.set_price(new_price); - assert_fails_with( - market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), - PerpError::PriceOutsideBand, - ); - - // Every two minutes the keeper calls `update_price_average`. Each call - // credits the two minutes since the previous read to the price that read - // saw, a fifth of the window. The first call credits $100, the price - // before the move, and records $130; each later call moves the average a - // fifth of the remaining gap to $130: $100, then $106, then $110.80. $130 - // is within 20% of any average from $108.34 up, so the third update - // reopens trading. - let mut updates = 0; - loop { - market.pass_seconds(120); - market.set_price(new_price); - market.update_price_average(&keeper).unwrap(); - updates += 1; - let opened = - market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0); - if opened.is_ok() { - break; - } - assert_fails_with(opened, PerpError::PriceOutsideBand); - assert!(updates < 10, "the average never caught up"); - } - assert_eq!(updates, 3); - let pool = market.pool_state(); - assert_eq!(pool.average_price, 11_080_000_000); - assert_eq!(pool.last_oracle_price, new_price as u64); -} - -#[test] -fn test_single_update_moves_average_by_elapsed_fraction() { - let mut market = Market::default_market(); - let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); - let created_at = market.pool_state().average_price_timestamp; - - // The first update after the oracle moves to $115 credits the four - // minutes since creation to $100, the price seen at creation, so the - // average stays at $100 and $115 is recorded for the next read. - market.pass_seconds(240); - market.set_price(dollars(115)); - market.update_price_average(&keeper).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, dollars(100) as u64); - assert_eq!(pool.last_oracle_price, dollars(115) as u64); - assert_eq!(pool.average_price_timestamp, created_at + 240); - - // Four more minutes at $115 are 240 of the 600-second window, so the next - // update moves the average 240/600 of the way from $100 to $115: to $106. - market.pass_seconds(240); - market.set_price(dollars(115)); - market.update_price_average(&keeper).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, dollars(106) as u64); - assert_eq!(pool.average_price_timestamp, created_at + 480); - - // Fifteen minutes is more than a full window, so the next update replaces - // the average with $115, the price at the previous read, and records the - // fall to $97. One more update credits $97 for a full window. - market.pass_seconds(900); - market.set_price(dollars(97)); - market.update_price_average(&keeper).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, dollars(115) as u64); - assert_eq!(pool.last_oracle_price, dollars(97) as u64); - market.pass_seconds(900); - market.set_price(dollars(97)); - market.update_price_average(&keeper).unwrap(); - assert_eq!(market.pool_state().average_price, dollars(97) as u64); -} - -/// A pool left idle for more than a window cannot have its average set by one -/// read of a manipulated price. The read only records the price; the interval -/// before it is credited to the price seen at the read before. Once a read of -/// the real price replaces it, the manipulated price has moved the average -/// only by the seconds between the two reads. -#[test] -fn test_one_manipulated_read_after_idle_does_not_move_average() { - let mut market = Market::default_market(); - market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); - let attacker = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); - - // Fifteen idle minutes, then the oracle is pushed to $160 and the - // attacker calls `update_price_average`. The average stays at $100. - market.pass_seconds(900); - market.set_price(dollars(160)); - market.update_price_average(&attacker).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, dollars(100) as u64); - assert_eq!(pool.last_oracle_price, dollars(160) as u64); - - // Six seconds later the oracle is back at $100 and is read again. The six - // seconds are credited to $160: the average moves 6/600 of the $60 gap, - // to $100.60, and $100 replaces $160 as the latest observation. - market.pass_seconds(6); - market.set_price(dollars(100)); - market.update_price_average(&attacker).unwrap(); - let pool = market.pool_state(); - assert_eq!(pool.average_price, 10_060_000_000); - assert_eq!(pool.last_oracle_price, dollars(100) as u64); - - // An open at $160 is still refused. - market.set_price(dollars(160)); - assert_fails_with( - market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), - PerpError::PriceOutsideBand, - ); + assert!(Market::try_new(dollars(100), parameters).is_err()); } diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index dbef03779..47453e0df 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,65 +1,5 @@ # Changelog -## 2026-10-01 - -Replace the leverage cap with an initial margin. `initialize_pool`'s -`max_leverage` argument and `Pool::max_leverage` are now `initial_margin_bps`, -the net collateral a position must post to open, in basis points of its size -(1,000 is 10x). `initialize_pool` requires `maintenance_margin_bps < -initial_margin_bps <= 10_000`, refusing an initial margin at or below the -maintenance margin with the new `INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE` (19) and -one above 10,000 with `INVALID_PARAMETER`; `MAX_LEVERAGE_CEILING` is removed. -`open_position` checks `net_collateral * 10_000 >= size * initial_margin_bps` -and fails with `INITIAL_MARGIN_NOT_MET`, which takes `LEVERAGE_TOO_HIGH`'s code -(2). Its separate check that a new position starts above the maintenance margin -is removed, because the initial margin implies it; `POSITION_NOT_HEALTHY` -remains for `close_position`. An open fee larger than the posted collateral now -fails with `INSUFFICIENT_COLLATERAL` (17), as in the Anchor version, rather than -`INSUFFICIENT_LIQUIDITY`. - -Add a price band around a program-maintained average price. A fresh, confident -oracle print could still be wrong, and every handler traded at it. The pool now -keeps `average_price`, a time-weighted moving average of the oracle price, -`last_oracle_price`, the price at the most recent oracle read, and -`average_price_timestamp`. `initialize_pool` seeds the average and -`last_oracle_price` from the oracle. Every handler that reads the oracle credits -the seconds since the previous read to the price that read saw, -`average += (last_oracle_price - average) * min(elapsed, -PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new -constant at 600 seconds, and then records the price it read as -`last_oracle_price`. The price read now only counts from now, so a pool left -idle for a window or more cannot have its average set by one read of a -manipulated price: that price moves the average only if the oracle still shows -it at a later read, weighted by the seconds between the two reads. -`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse -a price outside `|price - average_price| * 10_000 <= average_price * -max_price_deviation_bps` with the new `PRICE_OUTSIDE_BAND` (21), checked against -the stored average before anything is folded in. `liquidate_position` folds and -records without the check. The new permissionless `update_price_average` -handler (discriminator 7) folds and records too, also without the check, so -keepers calling it repeatedly as time passes can walk the average to a genuine -move. `max_price_deviation_bps` is a -new `initialize_pool` argument, which must be above zero and below 10,000, or -the handler fails with the new `INVALID_PRICE_DEVIATION` (20). `shared.rs` has -`refresh_price_and_funding_within_band` for the four band-checked handlers -beside `refresh_price_and_funding` for the other two. - -Tested by `open_rejects_position_below_initial_margin` (formerly -`open_rejects_excess_leverage`, now checking both sides of the boundary), -`initialize_pool_records_the_margins_band_and_average`, -`initialize_pool_rejects_initial_margin_at_or_below_maintenance`, -`initialize_pool_rejects_price_deviation_outside_range`, -`open_rejected_when_oracle_jumps_outside_band`, -`close_rejected_when_oracle_jumps_outside_band`, -`liquidity_changes_rejected_when_oracle_jumps_outside_band`, -`liquidation_runs_outside_band`, `price_average_catches_up_after_genuine_move`, -`single_update_moves_average_by_elapsed_fraction` and -`one_manipulated_read_after_idle_does_not_move_average`. The default test pool -uses a 1,000 basis point initial margin and a 2,000 basis point band; -`profit_is_capped_at_the_reserved_notional` triples the price, far outside the -band, so it now calls `update_price_average` to record the new price, lets a -full window pass, and calls it again before closing. - ## 2026-09-30 Remove `set_funding_rate` (discriminator 7). The pool's authority could change diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index 6638b370a..4d533d4d6 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -30,32 +30,10 @@ math. This page only covers what differs in the Quasar version. Tests run in-process with [`quasar-svm`](https://github.com/blueshift-gg/quasar-svm). They build the program, set up a collateral mint, oracle feed, and funded -wallets, then exercise: - -- pool initialization, including its checks on the initial margin (above the - maintenance margin, at most 10,000 basis points) and the price band (above - zero, below 10,000 basis points) -- liquidity add/remove, and share inflation through a provider's own trades -- opening and closing a long in profit, and the initial margin on both sides - of its boundary -- stale-price, pre-restart-price, and wide-confidence rejection -- the funding-rate maximum, an operator's wallet on the lighter side earning - only the fixed rate, and funding that follows seconds rather than slots -- the price band: opens, closes, deposits and withdrawals refused when the - oracle jumps outside it, liquidation running outside it, the exact average - after one `update_price_average` - (`single_update_moves_average_by_elapsed_fraction`), and repeated updates - walking the average to a genuine move until trading resumes - (`price_average_catches_up_after_genuine_move`), and one manipulated read - after an idle window leaving the average where it was - (`one_manipulated_read_after_idle_does_not_move_average`) -- liquidation, reserved liquidity, and fee collection - -Program errors are `ProgramError::Custom` codes listed in -`instructions/shared.rs`, with the same names as the Anchor version's -`PerpError` variants in upper snake case: `INITIAL_MARGIN_NOT_MET` (2), -`INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE` (19), `INVALID_PRICE_DEVIATION` (20) and -`PRICE_OUTSIDE_BAND` (21) among them. `update_price_average` is discriminator 7. +wallets, then exercise pool initialization, liquidity add/remove, opening and +closing a long in profit, leverage rejection, the funding-rate maximum, an +operator's wallet on the lighter side earning only the fixed rate, funding that +follows seconds rather than slots, liquidation, and fee collection. ```bash cargo build-sbf diff --git a/finance/perpetual-futures/quasar/src/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs index 3bb38298b..c0ff398db 100644 --- a/finance/perpetual-futures/quasar/src/constants.rs +++ b/finance/perpetual-futures/quasar/src/constants.rs @@ -21,17 +21,8 @@ pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// the cluster's slot time, which the protocol lowers over time. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; -/// How many seconds of oracle prices the pool's `average_price` follows. Each -/// fold moves the average toward the price seen at the previous read by -/// `elapsed / window` of the gap between them, and an interval of a full window -/// or more replaces the average with that price. Ten minutes is long enough -/// that a price seen at two reads six seconds apart, about as long as a faulty -/// or manipulated oracle print lasts, moves the average by one percent of its -/// jump, and short enough that a genuine move is back inside the band within -/// minutes of repeated reads. Counted on the Clock's `unix_timestamp`, -/// like funding: it is a span of wall-clock time, and the second or two of -/// leader drift changes a fold's weight by well under one percent. -pub const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; +/// Upper bound on a pool's configurable `max_leverage`. +pub const MAX_LEVERAGE_CEILING: u16 = 100; /// Upper bound on a pool's `funding_rate_per_second`, in `FUNDING_PRECISION` /// units: 277 billionths of a position's size per second, just under 0.1% of diff --git a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs index 148d44d18..0f0b1daab 100644 --- a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs @@ -1,9 +1,7 @@ use { crate::{ constants::MINIMUM_LIQUIDITY, - instructions::shared::{ - err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, - }, + instructions::shared::{err, error, refresh_price_and_funding, traders_unrealized_pnl}, state::Pool, LpMintPda, }, @@ -56,7 +54,7 @@ pub fn handle_add_liquidity( let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding_within_band( + let price = refresh_price_and_funding( &mut accounts.pool, &accounts.oracle_feed, slot, diff --git a/finance/perpetual-futures/quasar/src/instructions/close_position.rs b/finance/perpetual-futures/quasar/src/instructions/close_position.rs index 07024da61..006513e16 100644 --- a/finance/perpetual-futures/quasar/src/instructions/close_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/close_position.rs @@ -2,8 +2,7 @@ use { crate::{ constants::SIDE_LONG, instructions::shared::{ - basis_points_of, err, error, position_funding, position_pnl, - refresh_price_and_funding_within_band, + basis_points_of, err, error, position_funding, position_pnl, refresh_price_and_funding, }, state::{Pool, Position}, }, @@ -49,7 +48,7 @@ pub fn handle_close_position( ) -> Result<(), ProgramError> { let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding_within_band( + let price = refresh_price_and_funding( &mut accounts.pool, &accounts.oracle_feed, slot, diff --git a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs index ca466763e..6e0f57355 100644 --- a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs @@ -1,7 +1,7 @@ use { crate::{ - constants::{BASIS_POINTS_DENOMINATOR, MAX_FUNDING_RATE_PER_SECOND}, - instructions::shared::{err, error, read_feed_price}, + constants::{BASIS_POINTS_DENOMINATOR, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING}, + instructions::shared::{err, error}, state::{Pool, PoolInner}, LpMintPda, VaultPda, }, @@ -21,8 +21,7 @@ pub struct InitializePool { )] pub pool: Account, pub collateral_mint: Account, - /// CHECK: stored on the pool; every read, including the one here that seeds - /// the average price, validates layout, scale, freshness. + /// CHECK: stored on the pool; every read validates layout, scale, freshness. pub oracle_feed: UncheckedAccount, /// Liquidity-provider share mint; the pool account is its mint authority. #[account( @@ -56,11 +55,10 @@ pub fn handle_initialize_pool( funding_rate_per_second: u64, open_fee_bps: u16, close_fee_bps: u16, - initial_margin_bps: u16, + max_leverage: u16, maintenance_margin_bps: u16, liquidation_fee_bps: u16, max_confidence_bps: u16, - max_price_deviation_bps: u16, bumps: &InitializePoolBumps, ) -> Result<(), ProgramError> { let denominator = BASIS_POINTS_DENOMINATOR as u16; @@ -69,6 +67,9 @@ pub fn handle_initialize_pool( if funding_rate_per_second > MAX_FUNDING_RATE_PER_SECOND { return Err(err(error::INVALID_PARAMETER)); } + if !(1..=MAX_LEVERAGE_CEILING).contains(&max_leverage) { + return Err(err(error::INVALID_PARAMETER)); + } if open_fee_bps >= denominator || close_fee_bps >= denominator || liquidation_fee_bps >= denominator @@ -86,34 +87,10 @@ pub fn handle_initialize_pool( if maintenance_margin_bps <= close_fee_bps { return Err(err(error::INVALID_PARAMETER)); } - // A position must open with more margin than it is liquidated at, or it - // could be liquidated in the same slot it opened. At most 100% of - // notional: more than that would demand collateral above the position's - // size. - if initial_margin_bps <= maintenance_margin_bps { - return Err(err(error::INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE)); - } - if initial_margin_bps > denominator { - return Err(err(error::INVALID_PARAMETER)); - } if max_confidence_bps == 0 || max_confidence_bps >= denominator { return Err(err(error::INVALID_PARAMETER)); } - // Zero would refuse every price move, however small. At 100% or more the - // band could never refuse a fall, since the oracle price is always - // positive. - if max_price_deviation_bps == 0 || max_price_deviation_bps >= denominator { - return Err(err(error::INVALID_PRICE_DEVIATION)); - } - // Seed the average with a validated oracle price, so the band is in force - // from the first trade. - let initial_price = read_feed_price( - &accounts.oracle_feed, - oracle_scale, - accounts.clock.slot.get(), - max_confidence_bps, - )?; let unix_timestamp = accounts.clock.unix_timestamp.get(); accounts.pool.set_inner(PoolInner { authority: *accounts.authority.address(), @@ -132,17 +109,13 @@ pub fn handle_initialize_pool( short_size_scaled: 0, cumulative_funding: 0, last_funding_timestamp: unix_timestamp, - average_price: initial_price, - last_oracle_price: initial_price, - average_price_timestamp: unix_timestamp, funding_rate_per_second, open_fee_bps, close_fee_bps, - initial_margin_bps, + max_leverage, maintenance_margin_bps, liquidation_fee_bps, max_confidence_bps, - max_price_deviation_bps, bump: bumps.pool, }); Ok(()) diff --git a/finance/perpetual-futures/quasar/src/instructions/mod.rs b/finance/perpetual-futures/quasar/src/instructions/mod.rs index 7026141fc..00453e621 100644 --- a/finance/perpetual-futures/quasar/src/instructions/mod.rs +++ b/finance/perpetual-futures/quasar/src/instructions/mod.rs @@ -6,7 +6,6 @@ mod liquidate_position; mod open_position; mod remove_liquidity; pub mod shared; -mod update_price_average; pub use add_liquidity::*; pub use close_position::*; @@ -15,4 +14,3 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; -pub use update_price_average::*; diff --git a/finance/perpetual-futures/quasar/src/instructions/open_position.rs b/finance/perpetual-futures/quasar/src/instructions/open_position.rs index eaf87781f..61d0d557e 100644 --- a/finance/perpetual-futures/quasar/src/instructions/open_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/open_position.rs @@ -1,8 +1,8 @@ use { crate::{ - constants::{BASIS_POINTS_DENOMINATOR, SIDE_LONG, SIDE_SHORT}, + constants::{SIDE_LONG, SIDE_SHORT}, instructions::shared::{ - basis_points_of, err, error, refresh_price_and_funding_within_band, scale_size, + basis_points_of, err, error, refresh_price_and_funding, scale_size, }, state::{Pool, Position, PositionInner}, }, @@ -58,7 +58,7 @@ pub fn handle_open_position( let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding_within_band( + let price = refresh_price_and_funding( &mut accounts.pool, &accounts.oracle_feed, slot, @@ -76,29 +76,24 @@ pub fn handle_open_position( } } - // The open fee is taken out of the posted collateral; the rest backs the - // position, and the initial margin is measured against this net collateral. let open_fee = basis_points_of(size, accounts.pool.open_fee_bps.get())?; let net_collateral = collateral_amount .checked_sub(open_fee) - .ok_or_else(|| err(error::INSUFFICIENT_COLLATERAL))?; + .ok_or_else(|| err(error::INSUFFICIENT_LIQUIDITY))?; if net_collateral == 0 { return Err(err(error::ZERO_AMOUNT)); } - // Initial margin: net collateral must be at least `initial_margin_bps` of - // the notional size, compared as `net_collateral * 10_000 >= size * bps` - // so nothing is rounded. `initialize_pool` keeps the initial margin above - // the maintenance margin, so a position that passes this check opens with - // equity above the liquidation threshold. - let collateral_scaled = (net_collateral as u128) - .checked_mul(BASIS_POINTS_DENOMINATOR as u128) + let max_notional = (net_collateral as u128) + .checked_mul(accounts.pool.max_leverage.get() as u128) .ok_or(ProgramError::ArithmeticOverflow)?; - let required_scaled = (size as u128) - .checked_mul(accounts.pool.initial_margin_bps.get() as u128) - .ok_or(ProgramError::ArithmeticOverflow)?; - if collateral_scaled < required_scaled { - return Err(err(error::INITIAL_MARGIN_NOT_MET)); + if size as u128 > max_notional { + return Err(err(error::LEVERAGE_TOO_HIGH)); + } + + let maintenance = basis_points_of(size, accounts.pool.maintenance_margin_bps.get())?; + if net_collateral <= maintenance { + return Err(err(error::POSITION_NOT_HEALTHY)); } // Reserve liquidity to cover this position's maximum recoverable profit diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs index 5eeff7afd..20ce56e4d 100644 --- a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs @@ -1,9 +1,7 @@ use { crate::{ constants::MINIMUM_LIQUIDITY, - instructions::shared::{ - err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, - }, + instructions::shared::{err, error, refresh_price_and_funding, traders_unrealized_pnl}, state::Pool, LpMintPda, }, @@ -56,7 +54,7 @@ pub fn handle_remove_liquidity( let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding_within_band( + let price = refresh_price_and_funding( &mut accounts.pool, &accounts.oracle_feed, slot, diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index 4dca8c2bb..f4a08c1ac 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -7,14 +7,14 @@ use quasar_lang::{prelude::*, sysvars::Sysvar}; use crate::last_restart::LastRestartSlot; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, MAX_PRICE_STALENESS_SLOTS, - PRICE_AVERAGE_WINDOW_SECONDS, SIDE_LONG, SIZE_PRECISION, + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, MAX_PRICE_STALENESS_SLOTS, SIDE_LONG, + SIZE_PRECISION, }; use crate::state::Pool; pub mod error { pub const ZERO_AMOUNT: u32 = 0; - pub const INITIAL_MARGIN_NOT_MET: u32 = 2; + pub const LEVERAGE_TOO_HIGH: u32 = 2; pub const INVALID_PARAMETER: u32 = 3; pub const STALE_PRICE: u32 = 4; pub const NON_POSITIVE_PRICE: u32 = 5; @@ -31,9 +31,6 @@ pub mod error { pub const ORACLE_CONFIDENCE_TOO_WIDE: u32 = 16; pub const INSUFFICIENT_COLLATERAL: u32 = 17; pub const PRICE_PREDATES_RESTART: u32 = 18; - pub const INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE: u32 = 19; - pub const INVALID_PRICE_DEVIATION: u32 = 20; - pub const PRICE_OUTSIDE_BAND: u32 = 21; } #[inline(always)] @@ -270,136 +267,30 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result, - price: u64, - current_timestamp: i64, -) -> Result<(), ProgramError> { - let average_price_timestamp = pool.average_price_timestamp.get(); - if current_timestamp <= average_price_timestamp { - pool.last_oracle_price.set(price); - return Ok(()); - } - let elapsed = current_timestamp - .checked_sub(average_price_timestamp) - .ok_or_else(overflow)?; - let weight = elapsed.min(PRICE_AVERAGE_WINDOW_SECONDS); - - let average = pool.average_price.get() as i128; - // Multiply before dividing; the gap is signed, so the average moves down - // as readily as up. - let movement = (pool.last_oracle_price.get() as i128) - .checked_sub(average) - .ok_or_else(overflow)? - .checked_mul(weight as i128) - .ok_or_else(overflow)? - .checked_div(PRICE_AVERAGE_WINDOW_SECONDS as i128) - .ok_or_else(overflow)?; - let new_average = average.checked_add(movement).ok_or_else(overflow)?; - pool.average_price - .set(u64::try_from(new_average).map_err(|_| overflow())?); - pool.last_oracle_price.set(price); - pool.average_price_timestamp.set(current_timestamp); - Ok(()) -} - -/// Refuse an oracle `price` more than `max_price_deviation_bps` away from the -/// pool's stored `average_price`: -/// `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. -pub fn require_price_within_band(pool: &Account, price: u64) -> Result<(), ProgramError> { - let average_price = pool.average_price.get(); - let deviation_scaled = (price.abs_diff(average_price) as u128) - .checked_mul(BASIS_POINTS_DENOMINATOR as u128) - .ok_or_else(overflow)?; - let band_scaled = (average_price as u128) - .checked_mul(pool.max_price_deviation_bps.get() as u128) - .ok_or_else(overflow)?; - if deviation_scaled > band_scaled { - return Err(err(error::PRICE_OUTSIDE_BAND)); - } - Ok(()) -} - -/// Read and validate the oracle price from the feed account, checked for -/// freshness against `slot`. -pub fn read_feed_price( - oracle_feed: &UncheckedAccount, - expected_scale: u32, - slot: u64, - max_confidence_bps: u16, -) -> Result { - let view = oracle_feed.to_account_view(); - let data = view - .try_borrow() - .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; - read_oracle_price(&data, expected_scale, slot, max_confidence_bps) -} - -/// The preamble `liquidate_position` and `update_price_average` run: read a -/// validated oracle price, checked for freshness against `slot`, bring the -/// pool's funding index up to `unix_timestamp`, and fold the interval since the -/// previous read into the pool's average (see `fold_price_into_average`), so -/// the settlement that follows uses fresh numbers. -/// Centralized so no handler can settle a position against a stale funding -/// index. -/// -/// No band check: liquidation has to keep working through a genuine price -/// move, because that is when positions go underwater, and -/// `update_price_average` is how the average catches up with one. +/// The preamble every price-sensitive handler runs: read a validated oracle +/// price from the feed, checked for freshness against `slot`, then bring the +/// pool's funding index up to the current time, `unix_timestamp`, so the +/// settlement that follows uses fresh numbers for both. Centralized so no +/// handler can settle a position against a stale funding index. pub fn refresh_price_and_funding( pool: &mut Account, oracle_feed: &UncheckedAccount, slot: u64, unix_timestamp: i64, ) -> Result { - let price = read_pool_oracle_price(pool, oracle_feed, slot)?; - accrue_funding(pool, unix_timestamp)?; - fold_price_into_average(pool, price, unix_timestamp)?; - Ok(price) -} + let price = { + let view = oracle_feed.to_account_view(); + let data = view + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; + read_oracle_price( + &data, + pool.oracle_scale.get(), + slot, + pool.max_confidence_bps.get(), + )? + }; -/// The preamble for every handler that opens or closes a position or moves -/// liquidity: the same as `refresh_price_and_funding`, but first refuses a -/// price outside the band around the stored average, before anything is -/// folded in or the price is recorded. A single oracle print far from the -/// average therefore cannot open, close, deposit, or withdraw at that price. -pub fn refresh_price_and_funding_within_band( - pool: &mut Account, - oracle_feed: &UncheckedAccount, - slot: u64, - unix_timestamp: i64, -) -> Result { - let price = read_pool_oracle_price(pool, oracle_feed, slot)?; - require_price_within_band(pool, price)?; accrue_funding(pool, unix_timestamp)?; - fold_price_into_average(pool, price, unix_timestamp)?; Ok(price) } - -fn read_pool_oracle_price( - pool: &Account, - oracle_feed: &UncheckedAccount, - slot: u64, -) -> Result { - read_feed_price( - oracle_feed, - pool.oracle_scale.get(), - slot, - pool.max_confidence_bps.get(), - ) -} diff --git a/finance/perpetual-futures/quasar/src/instructions/update_price_average.rs b/finance/perpetual-futures/quasar/src/instructions/update_price_average.rs deleted file mode 100644 index f7e82be1f..000000000 --- a/finance/perpetual-futures/quasar/src/instructions/update_price_average.rs +++ /dev/null @@ -1,34 +0,0 @@ -use { - crate::{instructions::shared::refresh_price_and_funding, state::Pool}, - quasar_lang::{prelude::*, sysvars::clock::Clock}, - quasar_spl::prelude::*, -}; - -#[derive(Accounts)] -pub struct UpdatePriceAverage { - /// Anyone may update the average: the result depends only on the oracle - /// price and the clock, never on who calls. - pub caller: Signer, - #[account( - mut, - address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), - )] - pub pool: Account, - /// CHECK: bound to the pool via its seeds. - pub oracle_feed: UncheckedAccount, - pub collateral_mint: Account, - pub clock: Sysvar, -} - -#[inline(always)] -pub fn handle_update_price_average(accounts: &mut UpdatePriceAverage) -> Result<(), ProgramError> { - let slot = accounts.clock.slot.get(); - let unix_timestamp = accounts.clock.unix_timestamp.get(); - refresh_price_and_funding( - &mut accounts.pool, - &accounts.oracle_feed, - slot, - unix_timestamp, - )?; - Ok(()) -} diff --git a/finance/perpetual-futures/quasar/src/lib.rs b/finance/perpetual-futures/quasar/src/lib.rs index dfbfee805..b21d656ce 100644 --- a/finance/perpetual-futures/quasar/src/lib.rs +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -40,11 +40,10 @@ mod quasar_perpetual_futures { funding_rate_per_second: u64, open_fee_bps: u16, close_fee_bps: u16, - initial_margin_bps: u16, + max_leverage: u16, maintenance_margin_bps: u16, liquidation_fee_bps: u16, max_confidence_bps: u16, - max_price_deviation_bps: u16, ) -> Result<(), ProgramError> { instructions::handle_initialize_pool( &mut ctx.accounts, @@ -52,11 +51,10 @@ mod quasar_perpetual_futures { funding_rate_per_second, open_fee_bps, close_fee_bps, - initial_margin_bps, + max_leverage, maintenance_margin_bps, liquidation_fee_bps, max_confidence_bps, - max_price_deviation_bps, &ctx.bumps, ) } @@ -124,15 +122,4 @@ mod quasar_perpetual_futures { pub fn collect_fees(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_collect_fees(&mut ctx.accounts, &ctx.bumps) } - - /// Read the oracle, credit the seconds since the previous read to the - /// price that read saw, record the current price for the next read, and - /// accrue funding up to now. Permissionless: after a genuine price move - /// takes the oracle outside the pool's band, anyone can call this - /// repeatedly as time passes to walk the average toward the new price until - /// trading resumes. - #[instruction(discriminator = 7)] - pub fn update_price_average(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_update_price_average(&mut ctx.accounts) - } } diff --git a/finance/perpetual-futures/quasar/src/state.rs b/finance/perpetual-futures/quasar/src/state.rs index dea3c04b2..b3828bbe7 100644 --- a/finance/perpetual-futures/quasar/src/state.rs +++ b/finance/perpetual-futures/quasar/src/state.rs @@ -32,37 +32,17 @@ pub struct Pool { /// the wall clock, so what a position costs per hour does not depend on /// the cluster's slot time. pub last_funding_timestamp: i64, - /// Time-weighted moving average of the oracle price, in the pool's - /// `oracle_scale` fixed point. Seeded with the oracle price when the pool is - /// created. Every handler that reads the oracle credits the seconds since - /// the previous read to `last_oracle_price`, the price that read saw. - /// Trading and liquidity handlers refuse an oracle price more than - /// `max_price_deviation_bps` away from it, so a sudden jump pauses them - /// until the average catches up. - pub average_price: u64, - /// The oracle price at the most recent read, in `oracle_scale` fixed point. - /// The next read folds it into `average_price` for the seconds in between. - pub last_oracle_price: u64, - /// The Clock's `unix_timestamp` of the most recent fold into - /// `average_price`. - pub average_price_timestamp: i64, /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. pub funding_rate_per_second: u64, pub open_fee_bps: u16, pub close_fee_bps: u16, - /// Net collateral a position must post to open, in basis points of its - /// notional size: 1_000 allows at most 10x leverage. Always above - /// `maintenance_margin_bps`, so no position opens already liquidatable. - pub initial_margin_bps: u16, + pub max_leverage: u16, pub maintenance_margin_bps: u16, pub liquidation_fee_bps: u16, /// Maximum oracle confidence band, in basis points of the price, the pool /// will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, - /// Widest gap the pool trades across between the oracle price and - /// `average_price`, in basis points of `average_price`. - pub max_price_deviation_bps: u16, pub bump: u8, } diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 0ca5c8ad6..e51ba9443 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -1,7 +1,6 @@ //! quasar-test integration tests. They exercise the full lifecycle: pool //! initialization, liquidity add/remove, opening/closing/liquidating leveraged -//! positions, fee collection, the price average and its band, and the -//! oracle/margin/reserve checks. +//! positions, fee collection, and the oracle/leverage/reserve guard rails. use { crate::{ @@ -9,9 +8,8 @@ use { cpi::{ AddLiquidityInstruction, ClosePositionInstruction, CollectFeesInstruction, InitializePoolInstruction, LiquidatePositionInstruction, OpenPositionInstruction, - RemoveLiquidityInstruction, UpdatePriceAverageInstruction, + RemoveLiquidityInstruction, }, - instructions::shared::error, state::{Pool, Position}, LpMintPda, VaultPda, }, @@ -44,11 +42,6 @@ const VICTIM_COLLATERAL: Pubkey = Pubkey::new_from_array([13; 32]); const VICTIM_LP: Pubkey = Pubkey::new_from_array([14; 32]); const OPERATOR_WALLET: Pubkey = Pubkey::new_from_array([15; 32]); const OPERATOR_COLLATERAL: Pubkey = Pubkey::new_from_array([16; 32]); -const KEEPER: Pubkey = Pubkey::new_from_array([17; 32]); - -// Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds -// replaces the pool's average price with the oracle price. -const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; // Ten years, in seconds. const TEN_YEARS: i64 = 315_360_000; @@ -122,40 +115,18 @@ fn init_pool_with_funding( funding_rate_per_second: u64, ) -> Outcome { test.send(InitializePoolInstruction { - maintenance_margin_bps, - close_fee_bps, - funding_rate_per_second, - ..default_initialize_pool() - }) -} - -/// The pool every test uses unless it overrides a parameter: 0.1% open and -/// close fees, a 10% initial margin (10x leverage), a 5% maintenance margin, a -/// 1% liquidation fee, a 1% maximum confidence band, a 20% price band around -/// the pool's average price, and no funding. -fn default_initialize_pool() -> InitializePoolInstruction { - InitializePoolInstruction { authority: ADMIN, collateral_mint: COLLATERAL_MINT, oracle_feed: FEED, oracle_scale: ORACLE_SCALE, - funding_rate_per_second: 0, + funding_rate_per_second, open_fee_bps: 10, - close_fee_bps: 10, - initial_margin_bps: 1_000, - maintenance_margin_bps: 500, + close_fee_bps, + max_leverage: 10, + maintenance_margin_bps, liquidation_fee_bps: 100, max_confidence_bps: 100, - max_price_deviation_bps: 2_000, - } -} - -/// The world `initialize_pool` needs: the admin, the collateral mint, and a -/// feed at $100. -fn add_pool_prerequisites(test: &mut Test) { - test.add(Wallet::new().at(ADMIN)); - test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); - set_feed(test, dollars(100), 0); + }) } /// The pool and its derived PDAs. @@ -166,7 +137,8 @@ struct Env { } /// Build a world with a collateral mint, an oracle feed at $100, and an -/// initialized pool with the parameters in `default_initialize_pool`. +/// initialized pool (0.1% open/close fees, 10x max leverage, 5% maintenance +/// margin, 1% liquidation fee, 1% max confidence). fn setup(test: &mut Test) -> Env { setup_with_funding(test, 0) } @@ -174,7 +146,9 @@ fn setup(test: &mut Test) -> Env { /// Like `setup`, but with a non-zero per-second funding rate so funding accrues /// as time passes. fn setup_with_funding(test: &mut Test, funding_rate_per_second: u64) -> Env { - add_pool_prerequisites(test); + test.add(Wallet::new().at(ADMIN)); + test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); + set_feed(test, dollars(100), 0); init_pool_with_funding(test, 500, 10, funding_rate_per_second).succeeds(); let pool = test.derive_pda(Pool::seeds(&COLLATERAL_MINT, &FEED)); @@ -235,35 +209,6 @@ fn open_position(test: &mut Test, env: &Env, side: u8, collateral: u64, size: u6 }) } -/// The pool's `(average_price, last_oracle_price, average_price_timestamp)`. -fn pool_state(test: &Test, env: &Env) -> (u64, u64, i64) { - let pool = test.read::(env.pool); - ( - u64::from(pool.average_price), - u64::from(pool.last_oracle_price), - i64::from(pool.average_price_timestamp), - ) -} - -/// Move the clock `seconds` past the pool's last average fold, publish `price` -/// at the new slot, and call `update_price_average`, which credits those -/// seconds to the price seen at the previous read and records `price`. -fn update_average_after(test: &mut Test, env: &Env, seconds: i64, price: i128) -> Outcome { - let (_, _, last_fold) = pool_state(test, env); - let timestamp = last_fold + seconds; - let slot = timestamp as u64 * SLOTS_PER_SECOND; - set_clock_at(test, slot, timestamp); - set_feed_at_slot(test, price, slot, 0); - if test.account(KEEPER).is_none() { - test.add(Wallet::new().at(KEEPER)); - } - test.send(UpdatePriceAverageInstruction { - caller: KEEPER, - oracle_feed: FEED, - collateral_mint: COLLATERAL_MINT, - }) -} - fn close_position(test: &mut Test, env: &Env) -> Outcome { test.send(ClosePositionInstruction { owner: TRADER, @@ -464,29 +409,16 @@ fn close_long_in_profit_pays_collateral_plus_pnl_minus_fees(test: &mut Test) { } #[quasar_test] -fn open_rejects_position_below_initial_margin(test: &mut Test) { +fn open_rejects_excess_leverage(test: &mut Test) { let env = setup(test); fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - fund(test, TRADER, TRADER_COLLATERAL, 2_000 * ONE_USDC); - // The initial margin is 10% of notional. 1,000 USDC of collateral less - // the 11 USDC open fee leaves 989 USDC, short of the 1,100 USDC an 11,000 - // USDC position needs. - open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, 11_000 * ONE_USDC) - .fails_with(error::INITIAL_MARGIN_NOT_MET); - - // A 10,000 USDC position needs 1,000 USDC net of its 10 USDC open fee. - // One minor unit short of 1,010 USDC is refused, and exactly 1,010 USDC - // opens at 10x. - let size = 10_000 * ONE_USDC; - let exact_collateral = 1_010 * ONE_USDC; - open_position(test, &env, SIDE_LONG, exact_collateral - 1, size) - .fails_with(error::INITIAL_MARGIN_NOT_MET); - open_position(test, &env, SIDE_LONG, exact_collateral, size).succeeds(); - assert_eq!( - u64::from(test.read::(env.pool).total_collateral), - size / 10 + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); + // 11x exceeds the 10x maximum. + assert!( + open_position(test, &env, 0, 1_000 * ONE_USDC, 11_000 * ONE_USDC).is_err(), + "11x leverage must be rejected" ); } @@ -544,6 +476,14 @@ fn collect_fees_sweeps_the_open_fee_to_the_admin(test: &mut Test) { .has_tokens(ADMIN_COLLATERAL, size / 1_000); } +/// Retuning the rate settles the seconds already elapsed at the old rate +/// rather than repricing them at the new one. +/// +/// Both halves below hold the same position for the same seconds at the same +/// price, so the size and price scaling cancels and only the rates differ: the +/// spanning position pays one window at the old rate plus one at the new (3 +/// window-rates), and the position opened afterwards pays one window wholly at +/// the new rate (2 window-rates). /// Funding is quoted per second of wall-clock time, so slots passing without /// the clock moving charge nothing. A million extra slots halfway through the /// window, as a much shorter slot would produce, leave the funding unchanged. @@ -593,9 +533,13 @@ fn funding_follows_seconds_not_slots(test: &mut Test) { #[quasar_test] fn initialize_pool_rejects_funding_rate_above_the_maximum(test: &mut Test) { // The rate is fixed at creation, so this is the only place it is checked. - add_pool_prerequisites(test); - init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND + 1) - .fails_with(error::INVALID_PARAMETER); + test.add(Wallet::new().at(ADMIN)); + test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); + set_feed(test, dollars(100), 0); + assert!( + init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND + 1).is_err(), + "a funding rate above the maximum must be rejected" + ); init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND).succeeds(); } @@ -697,13 +641,8 @@ fn profit_is_capped_at_the_reserved_notional(test: &mut Test) { open_position(test, &env, 0, collateral, size).succeeds(); // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). A move this large is - // far outside the price band, so the average has to catch up before the - // position can close: one update records $300, and a second a full window - // later credits that window to $300, replacing the average. + // profit is capped at the reserved notional (`size`). set_feed(test, dollars(300), 0); - update_average_after(test, &env, 0, dollars(300)).succeeds(); - update_average_after(test, &env, PRICE_AVERAGE_WINDOW_SECONDS, dollars(300)).succeeds(); let open_fee = size / 1_000; let close_fee = size / 1_000; @@ -737,261 +676,11 @@ fn initialize_pool_rejects_close_fee_at_or_above_maintenance_margin(test: &mut T // A pool whose close fee reached the maintenance margin could strand a // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. - add_pool_prerequisites(test); - init_pool(test, 500, 600).fails_with(error::INVALID_PARAMETER); -} - -#[quasar_test] -fn initialize_pool_records_the_margins_band_and_average(test: &mut Test) { - let env = setup(test); - let pool = test.read::(env.pool); - assert_eq!(u16::from(pool.initial_margin_bps), 1_000); - assert_eq!(u16::from(pool.max_price_deviation_bps), 2_000); - // The average starts at the oracle price the pool was created against. - assert_eq!(u64::from(pool.average_price), dollars(100) as u64); -} - -#[quasar_test] -fn initialize_pool_rejects_initial_margin_at_or_below_maintenance(test: &mut Test) { - // An initial margin at or below the 5% maintenance margin would let a - // position open already liquidatable. - add_pool_prerequisites(test); - for initial_margin_bps in [500, 350] { - test.send(InitializePoolInstruction { - initial_margin_bps, - ..default_initialize_pool() - }) - .fails_with(error::INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE); - } - // Above 100% of notional is refused too. - test.send(InitializePoolInstruction { - initial_margin_bps: 10_001, - ..default_initialize_pool() - }) - .fails_with(error::INVALID_PARAMETER); - // One basis point above the maintenance margin is accepted. - test.send(InitializePoolInstruction { - initial_margin_bps: 501, - ..default_initialize_pool() - }) - .succeeds(); -} - -#[quasar_test] -fn initialize_pool_rejects_price_deviation_outside_range(test: &mut Test) { - add_pool_prerequisites(test); - for max_price_deviation_bps in [0, 10_000] { - test.send(InitializePoolInstruction { - max_price_deviation_bps, - ..default_initialize_pool() - }) - .fails_with(error::INVALID_PRICE_DEVIATION); - } - test.send(InitializePoolInstruction { - max_price_deviation_bps: 9_999, - ..default_initialize_pool() - }) - .succeeds(); -} - -/// A single oracle print far from the pool's average cannot be traded at: the -/// open is refused before the price is folded into the average. -#[quasar_test] -fn open_rejected_when_oracle_jumps_outside_band(test: &mut Test) { - let env = setup(test); - fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); - add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); - let size = 5_000 * ONE_USDC; - - // The band is 20% around the $100 average: $125 and $79 are outside it. - for outside_price in [dollars(125), dollars(79)] { - set_feed(test, outside_price, 0); - open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, size) - .fails_with(error::PRICE_OUTSIDE_BAND); - // The refused open folded nothing into the average. - assert_eq!(pool_state(test, &env).0, dollars(100) as u64); - } - - // $118 is inside the band, and opens at that price. - set_feed(test, dollars(118), 0); - open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, size).succeeds(); - let position = test.read::(test.derive_pda(Position::seeds(&env.pool, &TRADER))); - assert_eq!(u64::from(position.entry_price), dollars(118) as u64); -} - -#[quasar_test] -fn close_rejected_when_oracle_jumps_outside_band(test: &mut Test) { - let env = setup(test); - fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); - add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - fund(test, TRADER, TRADER_COLLATERAL, collateral); - open_position(test, &env, SIDE_LONG, collateral, size).succeeds(); - - // A jump to $125 would pay the long $1,250, but $125 is 25% from the - // $100 average, outside the 20% band. - set_feed(test, dollars(125), 0); - close_position(test, &env).fails_with(error::PRICE_OUTSIDE_BAND); - - // At $115, inside the band, the close goes through and pays the 15% gain. - set_feed(test, dollars(115), 0); - let fee = size / 1_000; - let profit = size * 15 / 100; - close_position(test, &env) - .succeeds() - .has_tokens(TRADER_COLLATERAL, collateral - fee + profit - fee); -} - -/// Liquidation has no band check: a genuine crash is when positions go -/// underwater, so the pool has to be able to liquidate through one. -#[quasar_test] -fn liquidation_runs_outside_band(test: &mut Test) { - let env = setup(test); - fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); - add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - fund(test, TRADER, TRADER_COLLATERAL, 1_100 * ONE_USDC); - open_position(test, &env, SIDE_LONG, 1_100 * ONE_USDC, 10_000 * ONE_USDC).succeeds(); - - // $75 is 25% below the $100 average, so the owner cannot close there. - set_feed(test, dollars(75), 0); - close_position(test, &env).fails_with(error::PRICE_OUTSIDE_BAND); - - test.add(Wallet::new().at(LIQUIDATOR)); - let position = test.derive_pda(Position::seeds(&env.pool, &TRADER)); - test.send(LiquidatePositionInstruction { - liquidator: LIQUIDATOR, - owner: TRADER, - oracle_feed: FEED, - collateral_mint: COLLATERAL_MINT, - custody_vault: env.custody_vault, - trader_collateral: TRADER_COLLATERAL, - liquidator_collateral: LIQUIDATOR_COLLATERAL, - }) - .succeeds() - .is_closed(position); - assert_eq!(u128::from(test.read::(env.pool).long_size), 0); -} - -#[quasar_test] -fn liquidity_changes_rejected_when_oracle_jumps_outside_band(test: &mut Test) { - let env = setup(test); - fund(test, PROVIDER, PROVIDER_COLLATERAL, 15_000 * ONE_USDC); - add_liquidity(test, &env, 10_000 * ONE_USDC).succeeds(); - let shares = test.tokens(PROVIDER_LP); - - // $76 is 24% below the $100 average. - set_feed(test, dollars(76), 0); - add_liquidity(test, &env, 5_000 * ONE_USDC).fails_with(error::PRICE_OUTSIDE_BAND); - remove_liquidity(test, &env, shares).fails_with(error::PRICE_OUTSIDE_BAND); -} - -/// After a genuine move outside the band, anyone can walk the average toward -/// the new price with `update_price_average`, and trading resumes once the -/// price is back inside the band. -#[quasar_test] -fn price_average_catches_up_after_genuine_move(test: &mut Test) { - let env = setup(test); - fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); - add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - let collateral = 1_000 * ONE_USDC; - let size = 5_000 * ONE_USDC; - fund(test, TRADER, TRADER_COLLATERAL, collateral); - - // NVDAx reprices from $100 to $130, 30% away from the average. - let new_price = dollars(130); - set_feed(test, new_price, 0); - open_position(test, &env, SIDE_LONG, collateral, size).fails_with(error::PRICE_OUTSIDE_BAND); - - // Every two minutes the keeper calls `update_price_average`. Each call - // credits the two minutes since the previous read to the price that read - // saw, a fifth of the window. The first call credits $100, the price - // before the move, and records $130; each later call moves the average a - // fifth of the remaining gap to $130: $100, then $106, then $110.80. $130 - // is within 20% of any average from $108.34 up, so the third update - // reopens trading. - let mut updates = 0; - loop { - update_average_after(test, &env, 120, new_price).succeeds(); - updates += 1; - let opened = open_position(test, &env, SIDE_LONG, collateral, size); - if opened.is_ok() { - break; - } - opened.fails_with(error::PRICE_OUTSIDE_BAND); - assert!(updates < 10, "the average never caught up"); - } - assert_eq!(updates, 3); - let (average_price, last_oracle_price, _) = pool_state(test, &env); - assert_eq!(average_price, 11_080_000_000); - assert_eq!(last_oracle_price, new_price as u64); -} - -#[quasar_test] -fn single_update_moves_average_by_elapsed_fraction(test: &mut Test) { - let env = setup(test); - let (_, _, created_at) = pool_state(test, &env); - - // The first update after the oracle moves to $115 credits the four - // minutes since creation to $100, the price seen at creation, so the - // average stays at $100 and $115 is recorded for the next read. - update_average_after(test, &env, 240, dollars(115)).succeeds(); - assert_eq!( - pool_state(test, &env), - (dollars(100) as u64, dollars(115) as u64, created_at + 240) - ); - - // Four more minutes at $115 are 240 of the 600-second window, so the next - // update moves the average 240/600 of the way from $100 to $115: to $106. - update_average_after(test, &env, 240, dollars(115)).succeeds(); - assert_eq!( - pool_state(test, &env), - (dollars(106) as u64, dollars(115) as u64, created_at + 480) - ); - - // Fifteen minutes is more than a full window, so the next update replaces - // the average with $115, the price at the previous read, and records the - // fall to $97. One more update credits $97 for a full window. - update_average_after(test, &env, 900, dollars(97)).succeeds(); - assert_eq!( - pool_state(test, &env), - (dollars(115) as u64, dollars(97) as u64, created_at + 1_380) + test.add(Wallet::new().at(ADMIN)); + test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); + set_feed(test, dollars(100), 0); + assert!( + init_pool(test, 500, 600).is_err(), + "close_fee_bps >= maintenance_margin_bps must be rejected" ); - update_average_after(test, &env, 900, dollars(97)).succeeds(); - assert_eq!(pool_state(test, &env).0, dollars(97) as u64); -} - -/// A pool left idle for more than a window cannot have its average set by one -/// read of a manipulated price. The read only records the price; the interval -/// before it is credited to the price seen at the read before. Once a read of -/// the real price replaces it, the manipulated price has moved the average -/// only by the seconds between the two reads. -#[quasar_test] -fn one_manipulated_read_after_idle_does_not_move_average(test: &mut Test) { - let env = setup(test); - fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); - add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - let collateral = 1_000 * ONE_USDC; - fund(test, TRADER, TRADER_COLLATERAL, collateral); - - // Fifteen idle minutes, then the oracle is pushed to $160 and - // `update_price_average` is called. The average stays at $100. - update_average_after(test, &env, 900, dollars(160)).succeeds(); - let (average_price, last_oracle_price, _) = pool_state(test, &env); - assert_eq!(average_price, dollars(100) as u64); - assert_eq!(last_oracle_price, dollars(160) as u64); - - // Six seconds later the oracle is back at $100 and is read again. The six - // seconds are credited to $160: the average moves 6/600 of the $60 gap, - // to $100.60, and $100 replaces $160 as the latest observation. - update_average_after(test, &env, 6, dollars(100)).succeeds(); - let (average_price, last_oracle_price, last_fold) = pool_state(test, &env); - assert_eq!(average_price, 10_060_000_000); - assert_eq!(last_oracle_price, dollars(100) as u64); - - // An open at $160 is still refused. - set_feed_at_slot(test, dollars(160), last_fold as u64 * SLOTS_PER_SECOND, 0); - open_position(test, &env, SIDE_LONG, collateral, 5_000 * ONE_USDC) - .fails_with(error::PRICE_OUTSIDE_BAND); }