diff --git a/.claude/skills/solana-anchor-claude-skill/LICENSE.md b/.claude/skills/solana-anchor-claude-skill/LICENSE.md new file mode 100644 index 00000000..f52a392f --- /dev/null +++ b/.claude/skills/solana-anchor-claude-skill/LICENSE.md @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Quiknode Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/.claude/skills/solana-anchor-claude-skill/RUST.md b/.claude/skills/solana-anchor-claude-skill/RUST.md new file mode 100644 index 00000000..6bd2bab9 --- /dev/null +++ b/.claude/skills/solana-anchor-claude-skill/RUST.md @@ -0,0 +1,164 @@ +# Rust Guidelines (Anchor Programs) + +These guidelines apply to Anchor programs and any Rust crates that use Solana dependencies. Read this alongside the general rules in [SKILL.md](SKILL.md). + +## Anchor Version + +- Write all code like the latest stable Anchor (currently 1.0.2 but there may be a newer version by the time you read this) +- Use LiteSVM and Rust tests for new Anchor programs. `anchor init` uses LiteSVM by default. +- Do not use unnecessary macros that are not needed in the latest stable Anchor +- Don't implement instruction handlers as methods on account structs. There's no reason to tie state to functions, the function is not modifying the state (if we did like OOP, which we don't), and the functions and structs work without doing this, so there's no reason to implement instruction handlers as methods on account structs. + +## Anchor has silly defaults + +Every project will need an IDL. + +```toml +[features] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +``` + +and if it uses Tokens (like almost every Anchor project) it will need this dependency (insert whatever version is applicable): + +```toml +[dependencies] +anchor-spl = "1.0.2" +``` + +## Project Structure + +- **Never modify the program ID** in `lib.rs` or `Anchor.toml` when making changes +- Create files inside the `state` folder for whatever state is needed +- Create files inside the `instructions` or `handlers` folders (whichever exists) for whatever instruction handlers are needed +- Put Account Constraints in instruction files, but ensure the names end with `AccountConstraints` rather than just naming them the same thing as the function +- Handlers that are only for the admin should be in a new folder called `admin` inside whichever parent folder exists (`instructions/admin/` or `handlers/admin/`) + +## Account Constraints + +- Use a newline after each key in the account constraints struct, so the macro and the matching key/value have some space from other macros and their matching key/value + +## Bumps + +- Use `context.bumps.foo` not `context.bumps.get("foo").unwrap()` - the latter is outdated + +## Data Structures + +- When making structs ensure strings and Vectors have a `max_len` attribute +- Vectors have two numbers for `max_len`: the first is the max length of the vector, the second is the max length of the items in the vector + +## Space Calculation (CRITICAL - NO MAGIC NUMBERS) + +- **Do not use magic numbers anywhere**. I don't want to see `8 + 32` or whatever. +- **Do not make constants for the sizes of various data structures** +- For `space`, use syntax like: `space = SomeStruct::DISCRIMINATOR.len() + SomeStruct::INIT_SPACE,` +- All structs should have `#[derive(InitSpace)]` added to them, to get the `INIT_SPACE` trait +- **DO NOT use magic numbers** + +**Example:** + +```rust +#[derive(InitSpace)] +#[account] +pub struct UserProfile { + pub authority: Pubkey, + + #[max_len(50)] + pub username: String, + + pub bump: u8, +} + +#[derive(Accounts)] +pub struct InitializeProfile<'info> { + #[account( + init, + payer = authority, + space = UserProfile::DISCRIMINATOR.len() + UserProfile::INIT_SPACE, + seeds = [b"profile", authority.key().as_ref()], + bump + )] + pub profile: Account<'info, UserProfile>, + + #[account(mut)] + pub authority: Signer<'info>, + + pub system_program: Program<'info, System>, +} +``` + +## Error Handling + +- Return useful error messages +- Write code to handle common errors like insufficient funds, bad values for parameters, and other obvious situations +- All arithmetic in onchain code is `checked_*` — never raw `+ - * /`. Solana's BPF doesn't trap on overflow in release builds; silent wraps are how hacks happen. `checked_*` returns `Option`; force the error with `.ok_or(MyError::MathOverflow)?`. Reserve `saturating_*` for cosmetic/UX display values, never for balances. + +## Onchain Financial Math + +Applies to any code touching money, balances, prices, shares, fees, or token amounts. These rules are non-negotiable. + +- **Integers only — no floats, no fixed-point libraries.** Floats are non-deterministic across platforms (different validators could disagree on state). `fixed::types::I64F64`, `rust_decimal`, `bnum`-fixed-point and similar are also out — they add audit surface, burn compute, and hide the rounding/precision decisions you should be making explicitly. Token amounts are integers (base units), prices are ratios of integers. The system is discrete. Production Solana AMMs (Orca, Raydium, Meteora, Saber, Phoenix) all use raw `u128`. If you find yourself reaching for a decimal type, stop — the right tool is `u128` with discipline. +- **Multiply before you divide.** `a * b / c`, not `(a / c) * b`. Division truncates; dividing first throws away precision permanently. +- **Use `u128` (or wider) for intermediate products.** `u64 * u64` overflows at ~1.8e19. Cast both operands to `u128` _before_ multiplying, then narrow the final result with `try_into().map_err(|_| MyError::MathOverflow)?`. +- **Round in the protocol's favour, never the user's.** Value-to-share and share-to-value conversions: user gets floor, protocol gets ceil. Otherwise you leak 1 base unit per transaction forever, and attackers will industrialise it. +- **Validate ranges before doing the math.** Reject zero inputs, `amount > balance`, ratios that would mint zero shares. Cheap, prevents the inflation/donation attack on empty pools and other whole bug classes. +- **Check invariants after the math, not just before.** "K must not decrease" on a swap, "total LP shares == sum of holdings", "reserves >= owed fees". Compute, then `require!()` the invariant. +- **Decimals are tracked, not assumed.** USDC=6, SOL=9, SPL tokens vary. Use `transfer_checked` (carries decimals in the CPI). Reserves hold raw base units; the UI does cosmetic conversion. Never hard-code `* 10^9`. +- **Oracle/price freshness is part of the math.** Check `last_updated_slot` and reject if older than N slots. A stale price means the calculation is wrong. +- **Oracle confidence is part of the math too.** Pull oracles (Pyth, Switchboard) report a price *and* a confidence/uncertainty band. Reject the update when the band is too wide relative to the price (e.g. `confidence * 10_000 / price > max_error_bps`); a wide band means the price is unreliable, and skipping this check is one of the most common oracle exploits. Where the feed offers it, prefer the EMA/TWAP price over the latest spot price for a mark that is harder to manipulate within a single block. (See `solana-labs/perpetuals` for a worked example.) +- **Checks-effects-interactions.** Update state before the token transfer CPI, not after. +- **Treat client-supplied values as adversarial.** If a handler takes `(amount_a, amount_b)`, verify each against onchain state, not against each other. +- **Test the branch the bug lives in.** Standard AMM/lending bugs sit in the _non-empty pool_, _post-swap_, _post-fee_, _rounding-edge_ branches. The happy path almost always works. Write the test that exercises the branch where the bug actually lives. +- **LP shares use different formulas for first deposit vs subsequent.** First deposit: shares = `sqrt(amount_a * amount_b)` (geometric mean bootstraps the pool). Subsequent deposits: shares = `min(amount_a * supply / reserve_a, amount_b * supply / reserve_b)` (proportional to share-of-pool). Using the geometric mean for every deposit is a real, repeated bug — test both branches separately. +- **For integer sqrt, hand-code Newton's method on `u128`** (~15 lines, as Uniswap V2 in Solidity / Saber in Rust do). Don't reach for a fixed-point crate for one sqrt. +- **Slippage protection: accept a `min_output_*` from the user and verify before the CPI.** Swaps, deposits, and withdraws all need it. Without it, sandwich attackers steal value across the price gap they create. +- **Never silently clamp user input to balance.** If a user asks to swap 100 and you clamp to 80 because that's the balance, the user's slippage check passes against the wrong amount. Either fail the instruction or return the actual amount so the client can validate. +- **Use `transfer_checked`, never raw `transfer`.** `transfer_checked` carries the mint and decimals through the CPI, so a wrong-mint or wrong-decimals account causes a CPI failure instead of a silent miscalculation. +- **For token program compatibility, use `anchor_spl::token_interface`** (`InterfaceAccount`, `InterfaceAccount`, `Interface`). The same code then works against both the Classic Token Program and the Token Extensions Program. +- **Oracle freshness uses slots, not unix time.** Slot count is what the runtime guarantees; `Clock::get()?.unix_timestamp` is validator-influenced. Check `last_updated_slot` against `Clock::get()?.slot` and reject if older than N slots. If you must use a unix timestamp (because the oracle only exposes one), state why in a comment. +- **Canonical pubkey ordering for two-asset pools.** Order mints so `mint_a.key() < mint_b.key()` (lexicographic on the 32-byte key). Same pool whether the user passes `(USDC, SOL)` or `(SOL, USDC)`. Enforce in the constraint, don't rely on the client. + +### Escrows, Vaults, and Escape Hatches + +- **Every escrow needs a cancel/withdraw instruction.** An escrow with no cancel locks abandoned offers forever — funds become unrecoverable when the counterparty disappears. The cancel must be callable by the maker (and only the maker) at any time before the trade settles. +- **Don't use `init_if_needed` for an account the wrong party would pay rent for.** Common bug: the taker's instruction lazily creates the maker's destination ATA via `init_if_needed`, so the taker pays the maker's rent. Either require the maker to pre-create their ATA or pass the rent payer explicitly. +- **Update state before the CPI.** Already in the list above, but worth repeating in the vault context: write the new balance/share count first, then transfer. A CPI that re-enters (rare on Solana but possible via callbacks) sees current state, not stale state. + +**Pattern to copy when ratio-clamping (Uniswap V2 style):** + +```rust +let pool_a = pool_a_amount as u128; +let pool_b = pool_b_amount as u128; +let amount_a_u128 = amount_a as u128; +let amount_b_u128 = amount_b as u128; + +// Multiply before divide; u128 prevents overflow. +let amount_b_required = amount_a_u128 + .checked_mul(pool_b).ok_or(ErrorCode::MathOverflow)? + .checked_div(pool_a).ok_or(ErrorCode::MathOverflow)?; + +let (final_a, final_b) = if amount_b_required <= amount_b_u128 { + (amount_a_u128, amount_b_required) +} else { + let amount_a_required = amount_b_u128 + .checked_mul(pool_a).ok_or(ErrorCode::MathOverflow)? + .checked_div(pool_b).ok_or(ErrorCode::MathOverflow)?; + (amount_a_required, amount_b_u128) +}; + +let final_a: u64 = final_a.try_into().map_err(|_| ErrorCode::MathOverflow)?; +let final_b: u64 = final_b.try_into().map_err(|_| ErrorCode::MathOverflow)?; +``` + +## Cargo hygiene + +- Run `cargo clean` after finishing with a Rust project. Anchor `target/` directories accumulate fast (multi-GiB per project). +- If disk usage hits 85%, clean before doing more work. + +## PDA Management + +- Add `pub bump: u8` to every struct stored in PDA +- Save the bumps inside each when the struct inside the PDA is created + +## System Functions + +- When you get the time via Clock, use `Clock::get()?;` rather than `anchor_lang::solana_program::clock` diff --git a/.claude/skills/solana-anchor-claude-skill/SKILL.md b/.claude/skills/solana-anchor-claude-skill/SKILL.md new file mode 100644 index 00000000..c4f237e6 --- /dev/null +++ b/.claude/skills/solana-anchor-claude-skill/SKILL.md @@ -0,0 +1,439 @@ +--- +name: solana-anchor-claude-skill +description: "Use when working on Solana software, including one or more of: Solana client code using TypeScript, Rust libraries that use Solana crates, Anchor programs, Quasar programs, LiteSVM tests, including Rust program files, TypeScript tests, and Anchor.toml configuration. Designed to create minimal, reusable code without unnecessary duplication." +--- + +# Coding Guidelines + +Apply these rules to ensure code quality, maintainability, and adherence to project standards. + +## Fight for Truth + +Don't write things that aren't currently true — anywhere. Chat, code comments, variable names, PR titles, READMEs, commit messages. + +- Documentation and comments that do not match the code are considered untrue. +- Variable names that do not match the purpose of the variable are considered untrue. +- Temporary workarounds that aren't labelled as such are lying through omission - there is an issue you aren't telling the next programmer about. Mark them with a `TODO` comment with a link to a git issue (if it exists) and telling the next programmer when they can delete the workaround. +- If unsure of something, say so. Bluffing is lying. +- **Ambiguity is a soft lie:** if a phrase could be read two ways and only one is true, it's misleading. Disambiguate before sending — pick the term that says exactly what's meant, name the antecedent of every "it"/"this"/"that". +- A wrong statement is worse than no statement. +- Separate scratch labels from real identifiers. + +Actively fix untrue things when you see them. Don't let "close enough" wording stand in for the truthful one. + +**Grep before naming.** Before sending any prose, walkthrough, README, comment, or commit message that names a specific identifier (function, struct, file, account, module, field, constant), grep the source for that exact identifier and confirm it exists. "I'm pretty sure that's the name" is not enough. If the identifier doesn't exist, either use the real name or apply the rename to the code first, then write the prose. + +**Describe what is, not what was removed.** READMEs, doc-comments, and code comments document current state — not history. Lines like "no floats", "no longer uses X", "replaces the previous Y approach" belong in CHANGELOGs and PR descriptions, not source artefacts. A first-time reader has no history and "no longer uses I64F64" creates ambient confusion ("wait, should I be worried?"). Sweep before sending: grep for `no longer`, `removed`, `previously`, `used to`, `formerly`, `dropped`, `now uses`, `replaces the previous` — each hit is a candidate for deletion. + +## Do the whole thing + +The marginal cost of completeness is near zero with AI. Do the whole thing. + +Do it right. Do it with tests. Do it with documentation. Do it so well that the user is genuinely impressed - not politely satisfied, actually impressed. Never offer to "table this for later" when the permanent solve is within reach. Never leave a dangling thread when tying it off takes five more minutes. Never present a workaround when the real fix exists. + +The standard isn't "good enough" - it's "holy shit, that's done." Search before building. Test before shipping. + +Ship the complete thing. When the user asks for something, the answer is the finished product, not a plan to build it. Time is not an excuse. Fatigue is not an excuse. Complexity is not an excuse. Boil the ocean. + +## Success Criteria + +- Before declaring success, declaring that work is complete, or celebrating, run the project's actual tests using the correct command for that project (for example: `anchor test` for Anchor workspaces, the project's TypeScript test command for TypeScript clients/tests, or `cargo test` for Rust crates). If the tests fail, there is more work to do. Don't stop until the relevant test command passes on the code you have made. +- Do not write placeholder tests. Placeholder tests don't count as tests, placeholder tests passing does not achieve your task. + - Tests that just do `assert.ok(true)` or similar are placeholder tests and do not count as tests + - Tests that do not call the program's instruction handlers are placeholder tests and do not count as tests + - Tests must: initialize accounts, send transactions, verify state changes, check balances + - If you find yourself writing placeholder tests, stop and write real integration tests instead + - DO NOT mark "Write tests" as complete until tests actually call the program instructions + - DO NOT ask "should I write real tests now?" - if the tests are placeholders, write real ones immediately + +- Do not stop until documentation like `README.md` and `CHANGELOG.md` are also updated with your changes. If you have made a feature, and it is not documented in the README or changelog, there is more work to do and you must continue working. + +- When summarizing your work, show the work items you have achieved with this symbol '✅' and if there is any more work to do, add a '❌' for each remaining work item. + +## Documentation Sources + +Use these official documentation sources: + +- **Anchor**: https://www.anchor-lang.com/docs +- **LiteSVM**: https://www.anchor-lang.com/docs/testing/litesvm +- **Anchor Error Codes**: https://raw.githubusercontent.com/coral-xyz/anchor/master/lang/src/error.rs +- **Solana Kite**: https://solanakite.org +- **Solana Kit**: https://solanakit.com +- **Agave (Solana CLI)**: https://docs.anza.xyz/ (Anza makes the Solana CLI and Agave). +- **Switchboard** (if used): https://docs.switchboard.xyz/docs-by-chain/solana-svm +- **Arcium** (if used): https://docs.arcium.com/developers + +## Terminology + +- Remember this is Solana not Ethereum. Ethereum is not relevant to any documentation you write. Do not assume people know or care about Ethereum. + - Don't tell me about 'smart contracts' or 'protocols' (use 'programs' instead) + - Don't tell me about 'gas' (use 'transaction fees' instead) + - There are no 'mempools'. + - Do not tell me about other things that are not relevant to Solana. + +- Token program terminology: + - Use 'Token Extensions Program' or 'Token extensions' for the newer token program (not 'Token 2022' which is just a code name) + - Use 'Classic Token Program' for the older token program + - Use 'Token' rather than 'SPL Token' unless you are specifically discussing the distinction between the native token (SOL) and all other tokens (SPL Tokens) + +- Onchain / offchain (one word, no hyphen) + - Always write 'onchain' and 'offchain' as single, unhyphenated words — like 'online' and 'offline'. + - Never write 'on-chain' or 'off-chain'. The hyphenated forms are wrong. + - Apply the same rule to related terms: 'crosschain' (not 'cross-chain'), etc. + - Sources: + - [Solana Foundation style guide](https://solana.com/docs/references/terminology) + - [US Government usage](https://www.sec.gov/files/rules/interp/2026/33-11412.pdf) + - [Cat (catmcgee) will make fun of you if you write 'on-chain'](https://x.com/catmcgee/status/2028153588715761825) + +- Some tools in Solana unfortunately use the same word 'instructions' for both the input and the functions. To avoid confusion, use 'instruction handlers' for the functions that handle instructions, and 'instructions' for the input to those functions. + +## Do not use + +- Do not use 'Solana Labs' documentation. The company has been replaced by Anza. + +- Do not use 'Coral XYZ' documentation. Coral used to maintain Anchor, but Anchor is now maintained by the Solana Foundation (solana.org) + +- Do not use any documentaton or tools from Project Serum, which collapsed many years ago. + +- Do not use yarn. Yarn has no reason to exist and only adds unnecessary dependencies and is not commonly used for new JS/TS projects in 2026. Replace Yarn with npm everywhere you see it. Use npm for new projects as it does not require additional dependencies. Keep using pnpm if the project already uses pnpm. + +- Do not use **Switchboard Functions** - this product is dead and no longer maintained. (Note: Switchboard oracles are still active and usable.) + +- Do not use **Clockwork** - this product is dead. For scheduled instruction handler invocation, use [TukTuk](https://github.com/helium/tuktuk/tree/main/typescript-examples) instead. + +## Library versions + +Use the latest stable Anchor, Rust, TypeScript, Solana Kit, and Kite you can. If a bug occurs, favor updating rather than rolling back. + +## Project Documentation + +Every project must have a `README.md` file in the project root that includes: + +- **Purpose**: Why the project exists and what problem it solves +- **Major Concepts**: Key architectural concepts, important PDAs, state structures, and program logic +- **Testing**: How to run the tests (e.g., `anchor test`) +- **Setup**: Any prerequisites or setup steps needed to work with the project +- **Usage**: Basic usage examples or deployment instructions if applicable + +Keep the README focused and practical. Avoid generic boilerplate - write documentation that would actually help someone understand and work with this specific project. + +## Writing About Financial Software + +These apply to READMEs, docs, blog posts, and PR descriptions for finance-related projects (AMMs, escrows, lending, leasing, CLOBs, prediction markets, stablecoins). + +- **"Non-custodial" is a loaded word.** If the program locks funds in vaults during its lifecycle (every escrow, lending, AMM, leasing program does), don't claim "non-custodial" — it contradicts itself. What you usually mean is "no admin override, the rules are the deployed bytecode". Say that directly, or just describe the custody arrangement (program-owned vault, PDA signers, no admin escape hatch). +- **Upgrade authority is normal on Solana** — programs are usually upgradable so authors can ship security fixes. Don't apologise for it or treat it as disqualifying for "trustless" claims. Trust in the author/multisig is baseline; "trustless" means the documented rules can't be bypassed, not "bytecode frozen forever". +- **"Token" not "mint" in economic prose.** A mint is the onchain account that controls supply; a token is the asset. In economic descriptions ("post token A as collateral, borrow token B"), say "token A" and "token B". Reserve "mint account" for technical descriptions of what gets passed to instructions. +- **Tokens are fungible by default — don't say so.** Don't write "fungible token" or sentences explaining that tokens are fungible. The reader knows. Only qualify when contrasting ("non-fungible token" / NFT). Same rule as not explaining what an integer is. +- **One name per role/concept, enforced everywhere.** Pick a single term for each party (lessor/lessee, maker/taker, long/short, borrower/lender) and use ONLY that term throughout. Mixing terminology mid-document is how readers lose track of who owes what to whom. +- **Don't conflate "long the collateral" with "long the trade".** Anyone who posts collateral wants it to hold value (otherwise margin call), so every borrower is long their collateral. The directional bet is on the _borrowed_ asset, separately. Be precise about which "long" you mean. +- **Be careful with the word "securities".** It's a legal term. SOL is not a security. Asset-leasing is not "securities lending" even when the mechanics are analogous. Prefer "asset lending", "token lending", or "directional token lending" — and ask before picking one. +- **Spell out two-asset flows with concrete examples.** "Posts collateral and takes delivery of borrowed tokens" reads circular. "Posts USDC as collateral, borrows NVDAx" makes the asymmetry obvious. Don't make the reader infer that mints A and B are different things. +- **Name the instruction handlers in lifecycle prose.** When walking through "what the user does" (open position, close position, liquidate), name the actual handler (`take_lease`, `return_lease`, `liquidate`). Plain-English mechanics without handler names leave the reader unable to connect the narrative to the code. + +## General Coding Guidelines + +### You are a deletionist + +Your golden rule is "perfection isn't achieved when there's nothing more to add, rather perfection is achieved when there is nothing more to be taken away". + +Remove: + +- Comments that simply repeat what the code is doing, or the name of a variable, and do not add further insight. +- Repeated code that should be turned into a named function. +- Unused imports, unused constants, unused files, and comments that no longer apply. +- Doc-comments whose first line just paraphrases the identifier. `/// Pool authority PDA.` above `pub pool_authority` is noise. Either explain something the name doesn't (seed derivation, mutability rationale, type-choice reason, an invariant the reader can't see from the type) or delete the line. + +Don't remove existing comments unless they are no longer useful or accurate. + +### Communication Style + +- Do not make disclaimers about being a "complete project" or state what works +- It is expected that work is complete and functional - no need to state this explicitly +- Avoid phrases like "This is a complete implementation" or "All features are working" +- Just deliver the work without meta-commentary about its completeness + +### Config files: leave a comment explaining WHY + +When you change a configuration value, or pin a version in any config file (`Anchor.toml`, `Cargo.toml`, `package.json`, CI workflows, `.gitignore`, `rust-toolchain.toml`), leave a comment explaining _why_. The next reader needs the rationale, not just the value. + +- **Pinned versions:** what breaks without the pin? when can it be unpinned? +- **Non-default timeouts / limits:** why this number? +- **Removed sections:** what was it doing? why was it removed? +- **`.gitignore` exceptions:** why is this file tracked despite the rule? +- **Workarounds:** what's the proper fix? when can this be replaced? (mark with `TODO`) + +Example: + +```toml +# Pinned: 0.8.7 conflicts with litesvm's dep tree. +# Unpin when litesvm upgrades its ahash requirement. +ahash = "=0.8.6" +``` + +When you remove a section, only add why to the git commit, so the file is free of information that does not apply to its existing state. + +### Working with Generated or Unfamiliar Code + +**CRITICAL - Verify Before Use:** + +- Before calling ANY function whose signature you don't know with certainty, read the actual source code/type definitions first +- NEVER guess or assume what parameters a function accepts based on what seems logical +- Don't invent convenience parameters that don't exist +- Generated code, third-party libraries, and unfamiliar codebases often have different APIs than you expect +- Common mistake: Assuming a function accepts high-level parameters → WRONG. Check the actual signature in the source files first + +### Variable Naming + +Ensure good variable naming. Rather than add comments to explain what things are, give them useful names. + +**Don't do this:** + +```typescript +// Foo +const shlerg = getFoo(); +``` + +**Do this instead:** + +```typescript +const foo = getFoo(); +``` + +**Naming conventions:** + +- Arrays should be plurals (`shoes`), items within arrays should be the singular (`shoes.forEach((shoe) => {...})`) +- Functions should be verby, like `calculateFoo` or `getBar` +- Avoid abbreviations, use full words (e.g., use `context` rather than `ctx`). Never use `e` for something thrown, use `thrownObject`, never use `v` when you mean `value`. There is almost no case where a single character variable is a good idea outside maths (eg `p` and `q` for cryptography). +- Name a transaction some variant of `transaction`. Name instructions some variant of `instruction`. Name signatures some variant of `signature`. Do not confuse them - eg if the type looks like an instruction, you should not call it a 'transaction' because that is deceptive. + +You can still add comments for additional context, just be careful to avoid comments that are explaining things that would be better conveyed by good variable naming. + +### Code Quality + +- Avoid 'magic numbers'. Make numbers either have a good variable name, a comment explaining why they are that value, or a reference to the URL you got the value from. If the values come from an IDL, download the IDL, import it, and make a function that gets the value from the IDL rather than copying the value into the source code + +This is a magic number. Don't do this: + +```ts +const FINALIZE_EVENT_DISCRIMINATOR = new Uint8Array([ + 27, 75, 117, 221, 191, 213, 253, 249, +]); +``` + +Instead do this: + +```ts +const FINALIZE_EVENT_DISCRIMINATOR = getEventDiscriminator( + arciumIdl, + "FinalizeComputationEvent", +); +``` + +- The code you are making is for production. You shouldn't have comments like `// In production we'd do this differently` or `**Implementation incomplete** - Needs program config handling and proper PDA derivations` or `**WORK IN PROGRESS**` in the final code you produce, or functions that return placeholder data. Instead: do the fucking work. + +## Language-Specific Guidelines + +The rules above apply to every file in the project. In addition, read the file that matches the language you are editing: + +- **TypeScript** (Solana Kit clients, Solana Kit tests, browser code, anything `.ts`): see [TYPESCRIPT.md](TYPESCRIPT.md) +- **Rust** (Anchor programs, LiteSVM tests, Solana crates, anything `.rs`): see [RUST.md](RUST.md) + +If a task touches both sides, read both. + +### Testing (Rust + LiteSVM) + +Anchor 1.0+ ships Rust + LiteSVM tests by default — `anchor init` now scaffolds a Rust integration test under `programs//tests/`, and `Anchor.toml` sets `test = "cargo test"`. Use this as the sole test pattern for Anchor programs. Do not write TypeScript tests for Anchor programs. + +#### How to initialise a new project + +Always initialise new Anchor projects with both flags pinned explicitly: + +```sh +anchor init --package-manager npm --test-template litesvm +``` + +- `--package-manager npm` — `anchor init`'s default is `yarn`, which this skill bans. Pin npm at init time so you don't have to fix `Anchor.toml` afterwards. +- `--test-template litesvm` — currently the default in `anchor-cli`, but pin it explicitly so the project doesn't break if the default changes. The other templates (`mocha`, `jest`, `rust`, `mollusk`) are not used for new Anchor programs in this skill. + +The `--template` flag defaults to `multiple` (multi-file program layout with `instructions/`, `state.rs`, `error.rs`); keep that default. `--template single` is a single `lib.rs` and Anchor itself flags it as "not recommended for production". + +#### What `anchor init` gives you + +A fresh `anchor init` produces these test-related defaults: + +`Anchor.toml`: + +```toml +[toolchain] +package_manager = "yarn" + +[features] +resolution = true +skip-lint = false + +[scripts] +test = "cargo test" + +[hooks] +``` + +`programs//Cargo.toml` `[dev-dependencies]`: + +```toml +[dev-dependencies] +litesvm = "0.10.0" +solana-message = "3.0.1" +solana-transaction = "3.0.2" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +``` + +`programs//tests/test_initialize.rs`: + +```rust +use { + anchor_lang::{solana_program::instruction::Instruction, InstructionData, ToAccountMetas}, + litesvm::LiteSVM, + solana_message::{Message, VersionedMessage}, + solana_signer::Signer, + solana_keypair::Keypair, + solana_transaction::versioned::VersionedTransaction, +}; + +#[test] +fn test_initialize() { + let program_id = anchor_scaffold_probe::id(); + let payer = Keypair::new(); + let mut svm = LiteSVM::new(); + let bytes = include_bytes!("../../../target/deploy/anchor_scaffold_probe.so"); + svm.add_program(program_id, bytes).unwrap(); + svm.airdrop(&payer.pubkey(), 1_000_000_000).unwrap(); + + let instruction = Instruction::new_with_bytes( + program_id, + &anchor_scaffold_probe::instruction::Initialize {}.data(), + anchor_scaffold_probe::accounts::Initialize {}.to_account_metas(None), + ); + + let blockhash = svm.latest_blockhash(); + let msg = Message::new_with_blockhash(&[instruction], Some(&payer.pubkey()), &blockhash); + let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]).unwrap(); + + let res = svm.send_transaction(tx); + assert!(res.is_ok()); +} +``` + +Before the program binary exists, run `anchor build` so `target/deploy/.so` is on disk; the test loads it via `include_bytes!`. + +#### Two scaffold fixes to apply immediately after `anchor init` + +`anchor init`'s defaults conflict with this skill's rules. Fix them straight away: + +1. **Set `package_manager = "npm"` in `Anchor.toml`** — `anchor init` defaults to yarn, but yarn is banned in this skill. If you used `--package-manager npm` at init time you can skip this step. + + ```toml + [toolchain] + package_manager = "npm" + ``` + +2. **Delete `ts-mocha`, `mocha`, `chai` (and their `@types`) from `package.json`** — `--package-manager npm` does not remove the JS test dev-dependencies; you still need this step. The default JS test scaffold is stale. Anchor programs (since 1.0.0) use Rust + LiteSVM instead of TypeScript, not Mocha. If you keep a `package.json` at all (for offchain client code or scripts), it should not pull in Mocha-era dependencies. + +#### Minimal bare-bones test + +The `anchor init` scaffold above is already the minimal pattern — `litesvm` plus the `solana-*` primitives, no extra dependencies. Use this when you want zero indirection and complete control over the transaction. New tests can follow the same shape: build an `Instruction`, wrap in a `Message` with the latest blockhash, sign as a `VersionedTransaction`, and call `svm.send_transaction(tx)`. + +#### Optional ergonomic helpers via solana-kite + +[`solana-kite`](https://crates.io/crates/solana-kite) is an optional thin layer on top of `litesvm` that removes most of the manual transaction wiring. Used in the wild by [`quiknode-labs/solana-program-examples/basics/counter/anchor`](https://github.com/quiknode-labs/solana-program-examples/tree/main/basics/counter/anchor). + +Add to `[dev-dependencies]`: + +```toml +[dev-dependencies] +litesvm = "0.10.0" +solana-kite = "0.3.0" +borsh = "1.6.1" +``` + +The same test, rewritten with kite: + +```rust +use { + anchor_lang::{solana_program::instruction::Instruction, InstructionData, ToAccountMetas}, + litesvm::LiteSVM, + solana_kite::{create_wallet, send_transaction_from_instructions}, +}; + +#[test] +fn test_initialize() { + let program_id = anchor_scaffold_probe::id(); + let mut svm = LiteSVM::new(); + let bytes = include_bytes!("../../../target/deploy/anchor_scaffold_probe.so"); + svm.add_program(program_id, bytes).unwrap(); + + let payer = create_wallet(&mut svm, 1_000_000_000).unwrap(); + + let instruction = Instruction::new_with_bytes( + program_id, + &anchor_scaffold_probe::instruction::Initialize {}.data(), + anchor_scaffold_probe::accounts::Initialize {}.to_account_metas(None), + ); + + send_transaction_from_instructions(&mut svm, &[instruction], &payer, &[&payer]).unwrap(); +} +``` + +`create_wallet` replaces the `Keypair::new()` + `svm.airdrop(...)` pair, and `send_transaction_from_instructions` replaces the `Message` / `VersionedMessage` / `VersionedTransaction` construction. Bare `litesvm` is still the baseline — reach for kite when you have repeated boilerplate worth removing. + +#### Account deserialisation + +Anchor account data is `[8-byte discriminator][borsh-serialised struct]`. To read state from a LiteSVM test, fetch the account, skip the first 8 bytes, and `borsh`-decode the rest. Define a mirror struct (or import the program's own) that derives `BorshDeserialize`. + +```rust +use borsh::BorshDeserialize; + +#[derive(BorshDeserialize)] +struct CounterAccount { + pub count: u64, +} + +let account = svm.get_account(&counter_pda).unwrap(); +let counter = CounterAccount::try_from_slice(&account.data[8..]).unwrap(); +assert_eq!(counter.count, 1); +``` + +`8` here is the Anchor account discriminator length, not a magic number — it is fixed by Anchor's account layout. + +#### Re-expiring blockhash between repeated identical transactions + +LiteSVM, like a real validator, will reject a second transaction with the same blockhash + signer + message because the signature is identical to one it has already processed. If a test sends the *same* instruction twice (for example, calling `increment` in a loop), call `svm.expire_blockhash()` between sends so the next transaction picks up a fresh blockhash and is treated as new: + +```rust +send_transaction_from_instructions(&mut svm, &[increment.clone()], &payer, &[&payer]).unwrap(); +svm.expire_blockhash(); +send_transaction_from_instructions(&mut svm, &[increment], &payer, &[&payer]).unwrap(); +``` + +This is only needed when the message bytes would otherwise be byte-identical. Different instructions, different accounts, or different signers do not need it. + +#### Do not use + +- `solana-test-validator` — slow, stateful, replaced by LiteSVM for tests. +- `anchor test --validator legacy` — same reason; the default `anchor test` runs `cargo test` against LiteSVM. +- `anchor.setProvider`, `anchor.AnchorProvider.env()` — TS Anchor client wiring, no longer used for tests. +- `program.methods.X().rpc()`, `program.methods.X().sendAndConfirm()` — the TS `@coral-xyz/anchor` client; do not use it for tests. +- `ts-mocha`, `mocha`, `chai` — the stale `anchor init` JS test scaffold. +- `tsx`-based `node:test` for Anchor program tests — fine for offchain scripts, not for testing programs. +- `@solana/web3.js` v1 — legacy in any context. +- `@coral-xyz/anchor` — Anchor's old TS client; not used in this test pattern. +- `kit-plugin-litesvm` (the TypeScript LiteSVM plugin) — superseded by using the `litesvm` Rust crate directly. + +## Git commits + +Do not add "Co-Authored-By: Claude" or similar attribution when creating git commits. + +## Acknowledgment + +- Acknowledge these guidelines have been applied when working on this project to indicate you have read these rules and found that they do apply to this project. diff --git a/.claude/skills/solana-anchor-claude-skill/TYPESCRIPT.md b/.claude/skills/solana-anchor-claude-skill/TYPESCRIPT.md new file mode 100644 index 00000000..c58f32ce --- /dev/null +++ b/.claude/skills/solana-anchor-claude-skill/TYPESCRIPT.md @@ -0,0 +1,91 @@ +# TypeScript Guidelines + +These guidelines apply to TypeScript unit tests, browser code, Solana Kit clients, and any other places where TypeScript is used in the project. Read this alongside the general rules in [SKILL.md](SKILL.md). + +## General TypeScript + +Use `"type": "module"` in `package.json` files. + +Avoid using a `tsconfig.json` unless it's needed, as we use `tsx` to run most typescript and it doesn't usually need one. If you do need a `tsconfig.json`, state why at the top of the file, and you can use the most modern version of ECMAScript/JavaScript you want - up to say 2023. + +## Async/await + +Favor `async`/`await` and `try/catch` over `.then()` or `.catch()` or using callbacks for flow control. `tsx` has top level `await` so you don't need to wrap top level `await` in IIFEs. + +## Type System + +- **Always use `Array`**, never use `item[]` for consistency with other generic syntax like `Promise`, `Map`, and `Set` +- **Don't use `any`** + +## Comments + +- Most comments should use `//` and be above (not beside) the code +- The only exception is JSDoc/TSDoc comments which MUST use `/* */` syntax + +## Solana-Specific TypeScript + +- Don't make new `@solana/web3.js` version 1 code. Do not make new code using `@coral-xyz/anchor` package. Don't replace Solana Kit with web3.js version 1 code. web3.js version 1 is legacy and should be eventually removed. Solana Kit used to be called web3.js version 2. Use Solana Kit, preferably via Solana Kite. +- Use Kite's `connection.getPDAAndBump()` to turn seeds into PDAs and bumps +- There is no need to use offsets that you set to decode Solana account data - either download an npm package for the program like `@solana-program/token` for the token program or make one using Codama. +- In Solana Kit, you make instructions by making TS clients from IDLs using Codama. You can easily make Codama clients for installed IDLs using: + +`npx create-codama-clients` + +- Do not use the `bs58` npm package. + +Don't do this: + +```typescript +import bs58 from "bs58"; +const signature = bs58.encode(signatureBytes); +``` + +Do this instead: + +```typescript +import { getBase58Decoder } from "@solana/codecs"; +const signature = getBase58Decoder().decode(signatureBytes); +``` + +Yes, `bs58` and `@solana/codecs` packages have different concepts of 'encode' and 'decode'. + +## Unit Tests + +- Create unit tests in TS in the `tests` directory +- Use the Node.js inbuilt test and assertion libraries (then start the tests using `tsx` instead of `ts-mocha`) + +**Unit testing imports:** + +```typescript +import { before, describe, test } from "node:test"; +import assert from "node:assert"; +``` + +- Use `test` rather than `it` + +## Thrown object handling + +- JavaScript allows arbitrary items - strings, array, numbers etc to be 'thrown'. However you can assume that any non-Error item that is thrown is a programmer error. Handle it like this (including the comment since most TypeScript developers don't know this): + +```ts +// In JS it's possible to throw *anything*. A sensible programmer +// will only throw Errors but we must still check to satisfy +// TypeScript (and flag any craziness) +const ensureError = function (thrownObject: unknown): Error { + if (thrownObject instanceof Error) { + return thrownObject; + } + return new Error(`Non-Error thrown: ${String(thrownObject)}`); +}; +``` + +and + +```ts +try { + // some code that might throw +} catch (thrownObject) { + const error = ensureError(thrownObject); + throw error; +} +``` diff --git a/.gitignore b/.gitignore index 94ff54c0..4f211c70 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ node_modules/ /target deploy .claude/* -!.claude/skills/ \ No newline at end of file +!.claude/skills/ diff --git a/README.md b/README.md index 5a3c0869..d8ea7f50 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ A managed investment fund onchain. Investors deposit USDC and receive shares rep [⚓ Anchor](./finance/vault-strategy/anchor) +### Perpetual Futures + +A perpetual futures exchange — a venue for making leveraged bets on an asset's price without ever owning the asset. Traders post collateral and open a **long** (betting the price rises) or **short** (betting it falls) sized up to several times their collateral; their profit or loss tracks the price move and is paid in the collateral token. Rather than matching buyers to sellers, every trade is against a shared **liquidity pool** that other users fund and that is the counterparty to all of it — the pool pays winners and keeps losers' collateral, and its providers earn the trading and funding fees in return. The price comes from an oracle, positions accrue a funding fee over time, and anyone can **liquidate** a position whose collateral can no longer cover its loss. This is the design behind venues like Jupiter Perpetuals and GMX. + +[⚓ Anchor](./finance/perpetual-futures/anchor) [💫 Quasar](./finance/perpetual-futures/quasar) + ## Single concept examples ### Hello Solana diff --git a/finance/perpetual-futures/anchor/.gitignore b/finance/perpetual-futures/anchor/.gitignore new file mode 100644 index 00000000..be06d3aa --- /dev/null +++ b/finance/perpetual-futures/anchor/.gitignore @@ -0,0 +1,6 @@ +.anchor +target +**/*.rs.bk +node_modules +test-ledger +.DS_Store diff --git a/finance/perpetual-futures/anchor/Anchor.toml b/finance/perpetual-futures/anchor/Anchor.toml new file mode 100644 index 00000000..a9609192 --- /dev/null +++ b/finance/perpetual-futures/anchor/Anchor.toml @@ -0,0 +1,24 @@ +[toolchain] +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +perpetual_futures = "3uCm8Jep469pHUpYQCh6eA6dpYV3ogvTvaRDZBPtw5So" +mock_switchboard = "FnisQqhF56BxVYh5Wt8xW8wuTVN6STAGnk13MM5SRM7b" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "cargo test" + +# Non-default: the LiteSVM Rust tests load both programs' .so files via +# include_bytes!, so they must be built before `cargo test` runs. CI calls +# `anchor build` first; these waits only matter for the legacy validator path. +[test] +startup_wait = 5000 +shutdown_wait = 2000 diff --git a/finance/perpetual-futures/anchor/Cargo.toml b/finance/perpetual-futures/anchor/Cargo.toml new file mode 100644 index 00000000..f3977048 --- /dev/null +++ b/finance/perpetual-futures/anchor/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md new file mode 100644 index 00000000..c6527515 --- /dev/null +++ b/finance/perpetual-futures/anchor/README.md @@ -0,0 +1,228 @@ +# Perpetual Futures + +A perpetual futures exchange — a venue for making leveraged bets on an asset's price without ever owning the asset. It is modelled on the oracle-priced, pool-collateralized design used by [Jupiter Perpetuals](https://station.jup.ag/guides/perpetual-exchange/overview) and GMX (and the open-source [`solana-labs/perpetuals`](https://github.com/solana-labs/perpetuals) reference that [Adrena](https://github.com/AdrenaFoundation/adrena-program) and [Flash Trade](https://github.com/flash-trade/flash-perpetuals) fork), rather than the order-book design used by [Drift](https://docs.drift.trade/). + +The collateral is **USDC** (a dollar stablecoin), and the market tracks the price of **NVDAx**, a tokenised Nvidia share whose [oracle](#oracle) price follows the real stock. A second market could track **TSLAx** (Tesla); each market is one collateral token plus one price feed. In the tests these are mock [SPL tokens](https://solana.com/docs/terminology#token). + +A [perpetual future](https://www.investopedia.com/terms/f/futurescontract.asp) ("perp") is a [derivative](https://www.investopedia.com/terms/d/derivative.asp) with no expiry: profit and loss is paid in the collateral token as the price moves, and no stock or coin ever changes hands. + +[⚓ Anchor](.) · [💫 Quasar](../quasar) + +--- + +## Programs + +| Program | Description | +|---------|-------------| +| `perpetual-futures` | The exchange: pool creation, liquidity provision, opening/closing leveraged positions, funding, liquidation, and fee collection. | +| `mock-switchboard` | Test-only price feed. Stores a price, scale, last-update slot, and confidence band that tests write directly. Replaced by a real [Switchboard](https://docs.switchboard.xyz/) On-Demand feed in production. | + +All money math is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour — no floats, no fixed-point library. + +--- + +## Key Financial Concepts + +### 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: + +``` +long profit/loss = size * (price - entry_price) / entry_price +short profit/loss = size * (entry_price - price) / entry_price +``` + +### The liquidity pool and provider shares + +There is no order book. Every trade is against one shared [liquidity pool](https://www.investopedia.com/terms/l/liquidity.asp) that other users fund; the pool is the counterparty to all of them — it pays trader profits and keeps trader losses. Providers receive shares priced against [mark-to-market](https://www.investopedia.com/terms/m/marktomarket.asp) assets-under-management (the pool's value if every open position were settled now), derived from running per-side accumulators rather than by iterating positions. Pricing against the marked value stops a provider exiting just before an in-flight trader profit is realized. The first deposit mints `deposit - MINIMUM_LIQUIDITY` shares (the Uniswap V2 convention) so the share supply never starts at a dust amount. + +### Reserved liquidity + +So a winning trader can always be paid, the pool **reserves** liquidity to back each open position's maximum recoverable profit (its notional `size`). An open is allowed only while `reserved + size <= liquidity`, which doubles as an open-interest cap. `close_position` caps a winner's payout at the reserved `size` (for a long, profit is capped on a more-than-doubling move; a short's profit is naturally within `size`), and provider withdrawals can take only the *free* remainder (`liquidity - reserved`). This is the simplified, single-collateral form of the reserve accounting in `solana-labs/perpetuals`. + +### Funding + +[Funding](https://www.investopedia.com/terms/f/futurescontract.asp) anchors the pool's risk: the heavier side of [open interest](https://www.investopedia.com/terms/o/openinterest.asp) pays the pool over time. A cumulative funding index rises while longs are the larger side and falls while shorts are, advancing by `funding_rate_per_slot` each [slot](https://solana.com/docs/terminology#slot); a position records the index at open and settles the change when it closes. In a pool-based perp this is the equivalent of the borrow fee Jupiter Perpetuals charges. + +### Maintenance margin and liquidation + +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. + +### Oracle + +The mark price comes from an oracle feed. This example validates the price for staleness (by slot), 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. + +### 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 protocol. 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. + +--- + +## Program Flow + +### Participants + +| Person | Role | Motivation | +|--------|------|-----------| +| **Admin** | Pool authority | Operate the market and collect the protocol's slice of trading fees. | +| **Carol** | Liquidity provider | Earn fees by funding the pool and being the counterparty to traders. | +| **Alice** | Long trader | She has a thesis that NVDA will rise and wants leveraged upside without buying the stock. | +| **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; on-chain 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. + +--- + +### Step 1 — Admin opens the market + +**Instruction:** `initialize_pool(parameters)` + +**Accounts created:** + +| Account | Seeds / Derivation | What it stores | +|---------|--------------------|----------------| +| `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda) | `["pool", collateral_mint, oracle_feed]` | parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, protocol fees | +| `pool_authority` PDA | `["authority", pool]` | nothing; signs vault and mint CPIs | +| `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA | `["vault", pool]` | all USDC — both provider liquidity and trader collateral | +| `lp_mint` PDA | `["lp_mint", pool]` | the share [mint](https://solana.com/docs/terminology#mint-account); `pool_authority` is the mint authority | + +--- + +### Step 2 — Carol provides liquidity + +**Instruction:** `add_liquidity(amount = 100_000 USDC, minimum_shares_out)` + +**Accounts modified:** + +| Account | Change | +|---------|--------| +| `carol_usdc` | −100,000 USDC | +| `custody_vault` | +100,000 USDC | +| `lp_mint` → `carol_lp` (created) | mints ≈100,000 shares to Carol | +| `Pool.liquidity` | 0 → 100,000 | + +The pool can now pay trader winnings, and Carol holds shares representing her slice of it. + +--- + +### Step 3 — Alice opens a 5× long + +**Instruction:** `open_position(side = Long, collateral_amount = 1,000 USDC, size = 5,000 USDC, acceptable_price)` + +NVDAx is at $100. The 0.1% open fee ($5) comes out of her collateral, leaving $995 of net collateral backing the position. + +**Accounts modified:** + +| Account | Change | +|---------|--------| +| `Position` PDA `["position", pool, alice, Long]` (created) | side Long, collateral $995, size $5,000, entry price $100 | +| `alice_usdc` | −1,000 USDC | +| `custody_vault` | +1,000 USDC | +| `Pool.total_collateral` | +$995 | +| `Pool.protocol_fees` | +$5 | +| `Pool.reserved_liquidity` | +$5,000 (must stay ≤ liquidity) | +| `Pool` long open-interest accumulators | += this position | + +--- + +### Step 4 — Bob opens a 5× short + +**Instruction:** `open_position(side = Short, collateral_amount = 1,000 USDC, size = 5,000 USDC, acceptable_price)` + +**Accounts modified:** a `Position` PDA `["position", pool, bob, Short]` is created; `custody_vault` +1,000 USDC; `Pool.total_collateral` +$995; `Pool.protocol_fees` +$5; `Pool.reserved_liquidity` +$5,000 (now $10,000 of the $100,000 reserved); short open-interest accumulators rise. + +While both are open, **funding** accrues to the pool from the heavier side; it is settled when each position closes. + +--- + +### Step 5 — NVDA rises to $116. Alice closes in profit + +**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. + +**Accounts modified:** + +| Account | Change | +|---------|--------| +| `Pool.liquidity` | −$800 (providers pay her profit) | +| `Pool.reserved_liquidity` | −$5,000 (reserve released) | +| `Pool.total_collateral` | −$995 | +| `Pool.protocol_fees` | +$5 | +| 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 | + +--- + +### Step 6 — Bob's short is underwater. Dave liquidates it + +**Instruction:** `liquidate_position()` + +At $116 Bob's short has lost $800; his equity ($995 − $800 = $195) has fallen below the 5% maintenance margin ($250), so anyone may close it. + +**Accounts modified:** + +| Account | Change | +|---------|--------| +| short open-interest accumulators | −= Bob's position | +| `Pool.reserved_liquidity` | −$5,000 (reserve released) | +| `Pool.total_collateral` | −$995 | +| `Pool.liquidity` | +$800 (the loss accrues to providers) | +| `custody_vault` → `dave_usdc` (created) | $50 liquidation fee | +| `custody_vault` → `bob_usdc` | $145 remaining equity refunded | +| `Position` (Bob) | closed; rent returned to Bob | + +--- + +### Step 7 — Admin collects the protocol's fees + +**Instruction:** `collect_fees()` + +**Accounts modified:** `Pool.protocol_fees` → 0; `custody_vault` pays that amount to `admin_usdc`. + +--- + +### Step 8 — Carol withdraws + +**Instruction:** `remove_liquidity(shares, minimum_amount_out)` + +Carol burns her shares and redeems USDC. Her balance now reflects the fees the pool earned plus the net of traders' wins and losses while she was in. She can withdraw only the *free* liquidity — while a position is open, the part backing it is reserved and cannot be pulled out. + +**Accounts modified:** `lp_mint` burns Carol's shares; `Pool.liquidity` falls; `custody_vault` pays out USDC to `carol_usdc`. + +--- + +## Design notes and further reading + +The genuinely hard part of a perpetual-futures venue is keeping it solvent and permissionless *without* re-evaluating the entire market on every action. For a rigorous, formally-verified (Kani) treatment, see Anatoly Yakovenko's [percolator](https://github.com/aeyakovenko/percolator), an educational perp risk engine. It states three invariants this example also leans on, in simplified form: + +- **Realizable credit** — "protected principal is senior, positive PnL is junior, and source-domain positive credit cannot exceed realizable backing reserved for that domain." Here, provider capital is senior and trader profit is a junior claim against it: shares are priced against marked assets-under-management, and the pool reserves each position's payout up front (capping recoverable profit at the reserve) so a winner can always be paid. +- **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. + +--- + +## Limitations + +This is a teaching example, not an audited exchange. Notably: + +- A single position per side per trader, and one collateral token per pool. +- Recoverable profit is capped at the reserved notional, so the cap binds on a more-than-doubling move; a production venue would let profit run and absorb extreme moves with ADL, an insurance fund, and bankruptcy-residual accounting. +- Funding is a single time-decay index on the heavier side rather than a skew-weighted rate. + +--- + +## 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 and wide-confidence rejection, funding accrual, 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 +cargo test --manifest-path programs/perpetual-futures/Cargo.toml +``` + +`anchor build` first, so the LiteSVM tests can load each program's compiled `.so` via `include_bytes!`. diff --git a/finance/perpetual-futures/anchor/TERMINOLOGY.md b/finance/perpetual-futures/anchor/TERMINOLOGY.md new file mode 100644 index 00000000..9ef81ba1 --- /dev/null +++ b/finance/perpetual-futures/anchor/TERMINOLOGY.md @@ -0,0 +1,36 @@ +# Perpetual Futures Terminology + +Terms used in this example, in the sense they carry here. + +- **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. + Each is the opposite side of the pool's exposure. +- **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 + 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 + 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, + a position must keep to avoid liquidation. +- **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 + 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 + 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: + pool liquidity minus the aggregate unrealized profit traders are owed. +- **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 + a mock Switchboard On-Demand feed; production points at a real one. +- **Mark price** — the price positions are valued at. Here it is the oracle + price directly, with no separate mark/index distinction. diff --git a/finance/perpetual-futures/anchor/programs/mock-switchboard/Cargo.toml b/finance/perpetual-futures/anchor/programs/mock-switchboard/Cargo.toml new file mode 100644 index 00000000..6c23f709 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/mock-switchboard/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "mock_switchboard" +version = "0.1.0" +description = "Mock Switchboard On-Demand feed for testing the perpetual-futures program" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "mock_switchboard" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +anchor-lang = "1.0.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/finance/perpetual-futures/anchor/programs/mock-switchboard/src/lib.rs b/finance/perpetual-futures/anchor/programs/mock-switchboard/src/lib.rs new file mode 100644 index 00000000..2a3f7923 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/mock-switchboard/src/lib.rs @@ -0,0 +1,105 @@ +//! Mock Switchboard On-Demand feed for testing the perpetual-futures program. +//! +//! Real Switchboard On-Demand feeds are program-owned accounts whose data is +//! produced by an offchain oracle network and verified onchain via Ed25519 +//! signatures over the latest price update. That verification path is +//! out-of-scope for this teaching example, so this mock stores a single price +//! the test harness writes directly, plus the slot the update happened in. +//! +//! The perpetual-futures program reads this feed the same way it would read a +//! real feed: load the account, decode the layout, read `price`, `scale`, and +//! `last_update_slot` (see `perpetual_futures::state::oracle`). Swap this +//! program ID for `SBondMDrcV3K4kxZR1HNVT7osZxAHVHgYXL5Ze1oMUv` (Switchboard +//! On-Demand) and adapt the layout to consume real feeds in production. +//! +//! NOT FOR PRODUCTION. +use anchor_lang::prelude::*; + +declare_id!("FnisQqhF56BxVYh5Wt8xW8wuTVN6STAGnk13MM5SRM7b"); + +#[program] +pub mod mock_switchboard { + use super::*; + + /// Initialize the mock feed with an initial price. The signer becomes the + /// authority allowed to push later price updates. + pub fn initialize_feed( + context: Context, + price: i128, + scale: u32, + confidence: u64, + ) -> Result<()> { + let feed = &mut context.accounts.feed; + feed.authority = context.accounts.authority.key(); + feed.price = price; + feed.scale = scale; + feed.last_update_slot = Clock::get()?.slot; + feed.confidence = confidence; + Ok(()) + } + + /// Push a new price (and confidence band) to the mock feed. In real + /// Switchboard this would be a signed update from the oracle network; here it + /// is an authority-gated write, because the goal is to drive deterministic + /// test scenarios. + pub fn set_price( + context: Context, + price: i128, + confidence: u64, + ) -> Result<()> { + let feed = &mut context.accounts.feed; + feed.price = price; + feed.last_update_slot = Clock::get()?.slot; + feed.confidence = confidence; + Ok(()) + } +} + +#[derive(Accounts)] +pub struct InitializeFeedAccountConstraints<'info> { + #[account( + init, + payer = authority, + space = MockFeed::DISCRIMINATOR.len() + MockFeed::INIT_SPACE, + )] + pub feed: Account<'info, MockFeed>, + + #[account(mut)] + pub authority: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct SetPriceAccountConstraints<'info> { + #[account( + mut, + has_one = authority, + )] + pub feed: Account<'info, MockFeed>, + + pub authority: Signer<'info>, +} + +/// Mock of a Switchboard On-Demand feed. Real feeds carry many more fields +/// (median, range, sample window, signatures) — this is the bare minimum the +/// perpetual-futures program needs to do a price comparison. +#[derive(InitSpace)] +#[account] +pub struct MockFeed { + pub authority: Pubkey, + + /// Signed 128-bit fixed-point price. Real Switchboard prices are also i128. + pub price: i128, + + /// Number of decimal places implied by `price`. E.g. `scale = 8` means + /// `price = 200 * 10^8` represents $200.00000000. + pub scale: u32, + + pub last_update_slot: u64, + + /// Uncertainty band around `price`, in the same fixed point. Real feeds + /// report a standard-deviation-like confidence; consumers reject the price + /// when this is too wide relative to `price`. + pub confidence: u64, +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/Cargo.toml b/finance/perpetual-futures/anchor/programs/perpetual-futures/Cargo.toml new file mode 100644 index 00000000..0e134c18 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "perpetual_futures" +version = "0.1.0" +description = "Oracle-priced, LP-pool perpetual futures example (Jupiter Perps / GMX-style)" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "perpetual_futures" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +# init-if-needed lets add_liquidity create the provider's liquidity-provider +# token account on their first deposit. The provider is the payer, so this does +# not let one party fund another's rent. +anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } +anchor-spl = "1.0.0" +# Not used directly. Declared so Cargo feature unification turns on +# `no-entrypoint` for the spl-token that anchor-spl pulls in; without it the +# integration-test binary links two `entrypoint` symbols (this program's and +# spl-token's) and fails. +spl-token = { version = "9.0.0", features = ["no-entrypoint"] } +spl-associated-token-account = { version = "8.0.0", features = ["no-entrypoint"] } + +[dev-dependencies] +litesvm = "0.11.0" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +solana-kite = "0.3.0" +borsh = "1.6.1" +# The LiteSVM tests load the compiled mock oracle program; depending on the +# crate here lets the tests reuse its instruction-argument types and program ID. +mock_switchboard = { path = "../mock-switchboard", features = ["no-entrypoint"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs new file mode 100644 index 00000000..3bf65d18 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs @@ -0,0 +1,50 @@ +use anchor_lang::prelude::*; + +/// Basis-point denominator: 100% = 10_000 bps. All fee and margin parameters are +/// expressed in basis points and divided by this. +#[constant] +pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; + +/// Fixed-point precision for the cumulative funding index. The index is carried +/// as `i128` scaled by this factor so per-slot funding (a tiny ratio) keeps its +/// precision when integrated over many slots. +pub const FUNDING_PRECISION: i128 = 1_000_000_000; + +/// Fixed-point precision for the aggregate `size / entry_price` accumulators the +/// pool keeps per side. Lets mark-to-market assets-under-management be computed +/// from two running sums instead of iterating every open position. +pub const SIZE_PRECISION: u128 = 1_000_000_000; + +/// Liquidity-provider shares withheld from the first deposit. The first +/// depositor receives `deposit - MINIMUM_LIQUIDITY` shares rather than the full +/// amount, the same convention Uniswap V2 uses, so the share supply can never be +/// driven to a dust amount that rounding could exploit. (Share value here is +/// priced off tracked liquidity, not the vault token balance, so a direct +/// donation to the vault cannot move it.) +#[constant] +pub const MINIMUM_LIQUIDITY: u64 = 1_000; + +/// Reject an oracle price older than this many slots. Slot count is what the +/// runtime guarantees; unix timestamps are validator-influenced. ~150 slots is +/// roughly one minute at 400ms/slot. +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; + +#[constant] +pub const POOL_SEED: &[u8] = b"pool"; + +#[constant] +pub const AUTHORITY_SEED: &[u8] = b"authority"; + +#[constant] +pub const LP_MINT_SEED: &[u8] = b"lp_mint"; + +#[constant] +pub const VAULT_SEED: &[u8] = b"vault"; + +#[constant] +pub const POSITION_SEED: &[u8] = b"position"; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs new file mode 100644 index 00000000..06e90a23 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs @@ -0,0 +1,55 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum PerpError { + #[msg("Amount must be greater than zero")] + ZeroAmount, + + #[msg("First deposit must exceed the locked minimum liquidity")] + DepositTooSmall, + + #[msg("Computed share or token amount rounded down to zero")] + AmountRoundsToZero, + + #[msg("Arithmetic overflow")] + MathOverflow, + + #[msg("Requested leverage exceeds the pool maximum")] + LeverageTooHigh, + + #[msg("Pool parameter is outside the allowed range")] + InvalidParameter, + + #[msg("Oracle price has not been updated recently enough")] + StalePrice, + + #[msg("Oracle price must be positive")] + NonPositivePrice, + + #[msg("Oracle feed scale does not match the pool configuration")] + OracleScaleMismatch, + + #[msg("Oracle feed account data is too short to decode")] + OracleDataTooShort, + + #[msg("Oracle price confidence band is too wide to trust")] + OracleConfidenceTooWide, + + #[msg("Fill price is worse than the caller's acceptable price")] + SlippageExceeded, + + #[msg("Pool does not have enough free liquidity to satisfy this request")] + InsufficientLiquidity, + + #[msg("Pool is insolvent: liabilities exceed assets")] + PoolInsolvent, + + #[msg("Position is still healthy and cannot be liquidated")] + PositionHealthy, + + #[msg("Position equity is below maintenance margin; it must be liquidated, not closed")] + PositionNotHealthy, + + #[msg("No protocol fees are available to collect")] + NothingToClaim, +} 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 new file mode 100644 index 00000000..7ff831ca --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -0,0 +1,150 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{ + mint_to, transfer_checked, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, + }, +}; + +use crate::constants::{AUTHORITY_SEED, MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; +use crate::errors::PerpError; +use crate::instructions::shared::{accrue_funding, liquidity_provider_aum}; +use crate::state::Pool; + +pub fn handle_add_liquidity( + context: Context, + amount: u64, + minimum_shares_out: u64, +) -> Result<()> { + require!(amount > 0, PerpError::ZeroAmount); + + let price = crate::state::oracle::read_oracle_price( + &context.accounts.oracle_feed, + context.accounts.pool.oracle_scale, + context.accounts.pool.max_confidence_bps, + )?; + + let pool = &mut context.accounts.pool; + accrue_funding(pool, Clock::get()?.slot)?; + + let lp_supply = context.accounts.lp_mint.supply; + let shares: u64 = if lp_supply == 0 { + // Bootstrap: shares track collateral one-for-one, less the withheld + // minimum, so the share supply can never start at a dust amount. + amount + .checked_sub(MINIMUM_LIQUIDITY) + .ok_or(PerpError::DepositTooSmall)? + } else { + // shares = amount * supply / assets-under-management, floored so the + // depositor never receives more than their pro-rata claim. + let aum = liquidity_provider_aum(pool, price)?; + require!(aum > 0, PerpError::PoolInsolvent); + (amount as u128) + .checked_mul(lp_supply as u128) + .ok_or(PerpError::MathOverflow)? + .checked_div(aum as u128) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow)? + }; + + require!(shares > 0, PerpError::AmountRoundsToZero); + require!(shares >= minimum_shares_out, PerpError::SlippageExceeded); + + // Effects before interactions: record the new liquidity, then move tokens. + pool.liquidity = pool + .liquidity + .checked_add(amount) + .ok_or(PerpError::MathOverflow)?; + + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.provider_collateral.to_account_info(), + mint: context.accounts.collateral_mint.to_account_info(), + to: context.accounts.custody_vault.to_account_info(), + authority: context.accounts.provider.to_account_info(), + }, + ), + amount, + context.accounts.collateral_mint.decimals, + )?; + + let pool_key = pool.key(); + let authority_seeds: &[&[u8]] = &[AUTHORITY_SEED, pool_key.as_ref(), &[pool.authority_bump]]; + mint_to( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + MintTo { + mint: context.accounts.lp_mint.to_account_info(), + to: context.accounts.provider_lp.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + &[authority_seeds], + ), + shares, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct AddLiquidityAccountConstraints<'info> { + #[account(mut)] + pub provider: Signer<'info>, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + has_one = collateral_mint, + has_one = lp_mint, + has_one = custody_vault, + has_one = oracle_feed, + )] + pub pool: Box>, + + /// CHECK: PDA authority over the vault and liquidity-provider mint. + #[account( + seeds = [AUTHORITY_SEED, pool.key().as_ref()], + bump = pool.authority_bump, + )] + pub pool_authority: UncheckedAccount<'info>, + + /// CHECK: validated by the `has_one = oracle_feed` constraint on the pool. + pub oracle_feed: UncheckedAccount<'info>, + + pub collateral_mint: Box>, + + #[account(mut)] + pub lp_mint: Box>, + + #[account( + mut, + seeds = [VAULT_SEED, pool.key().as_ref()], + bump, + )] + pub custody_vault: Box>, + + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = provider, + associated_token::token_program = token_program, + )] + pub provider_collateral: Box>, + + #[account( + init_if_needed, + payer = provider, + associated_token::mint = lp_mint, + associated_token::authority = provider, + associated_token::token_program = token_program, + )] + pub provider_lp: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} 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 new file mode 100644 index 00000000..528d6e60 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs @@ -0,0 +1,150 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, +}; + +use crate::constants::{AUTHORITY_SEED, POOL_SEED, POSITION_SEED, VAULT_SEED}; +use crate::errors::PerpError; +use crate::instructions::shared::{accrue_funding, basis_point_fee, settle_position}; +use crate::state::{Pool, Position}; + +pub fn handle_close_position( + context: Context, + minimum_payout: u64, +) -> Result<()> { + let price = crate::state::oracle::read_oracle_price( + &context.accounts.oracle_feed, + context.accounts.pool.oracle_scale, + context.accounts.pool.max_confidence_bps, + )?; + + let pool = &mut context.accounts.pool; + accrue_funding(pool, Clock::get()?.slot)?; + + let position = &context.accounts.position; + let position_size = position.size; + let settlement = settle_position(pool, position, price)?; + let close_fee = basis_point_fee(position_size, pool.close_fee_bps)?; + + // Recoverable profit is capped at the reserved amount (the position's + // notional `size`), so the pool can always cover a winner. Losses are not + // capped. + let realized_pnl = settlement.profit_and_loss.min(position_size as i128); + let equity = settlement + .equity + .checked_sub(settlement.profit_and_loss) + .ok_or(PerpError::MathOverflow)? + .checked_add(realized_pnl) + .ok_or(PerpError::MathOverflow)?; + + // The trader receives their equity minus the close fee. A non-positive + // payout means the position is underwater and must go through liquidation, + // not a voluntary close. + let payout = equity + .checked_sub(close_fee as i128) + .ok_or(PerpError::MathOverflow)?; + require!(payout > 0, PerpError::PositionNotHealthy); + let payout: u64 = payout.try_into().map_err(|_| PerpError::MathOverflow)?; + require!(payout >= minimum_payout, PerpError::SlippageExceeded); + + // Release the position's reserved liquidity now that it is closing. + pool.reserved_liquidity = pool + .reserved_liquidity + .checked_sub(position_size) + .ok_or(PerpError::MathOverflow)?; + + // Liquidity providers are the counterparty: they pay the trader's (capped) + // profit and receive their loss, and collect the funding the trader owed. + let liquidity_delta = settlement + .funding + .checked_sub(realized_pnl) + .ok_or(PerpError::MathOverflow)?; + let new_liquidity = (pool.liquidity as i128) + .checked_add(liquidity_delta) + .ok_or(PerpError::MathOverflow)?; + require!(new_liquidity >= 0, PerpError::PoolInsolvent); + pool.liquidity = new_liquidity + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + pool.protocol_fees = pool + .protocol_fees + .checked_add(close_fee) + .ok_or(PerpError::MathOverflow)?; + + let pool_key = pool.key(); + let authority_seeds: &[&[u8]] = &[AUTHORITY_SEED, pool_key.as_ref(), &[pool.authority_bump]]; + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.custody_vault.to_account_info(), + mint: context.accounts.collateral_mint.to_account_info(), + to: context.accounts.trader_collateral.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + &[authority_seeds], + ), + payout, + context.accounts.collateral_mint.decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct ClosePositionAccountConstraints<'info> { + #[account(mut)] + pub owner: Signer<'info>, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + has_one = collateral_mint, + has_one = custody_vault, + has_one = oracle_feed, + )] + pub pool: Box>, + + #[account( + mut, + close = owner, + seeds = [POSITION_SEED, pool.key().as_ref(), owner.key().as_ref(), position.side.as_seed()], + bump = position.bump, + has_one = owner, + has_one = pool, + )] + pub position: Box>, + + /// CHECK: PDA authority over the vault. + #[account( + seeds = [AUTHORITY_SEED, pool.key().as_ref()], + bump = pool.authority_bump, + )] + pub pool_authority: UncheckedAccount<'info>, + + /// CHECK: validated by the `has_one = oracle_feed` constraint on the pool. + pub oracle_feed: UncheckedAccount<'info>, + + pub collateral_mint: Box>, + + #[account( + mut, + seeds = [VAULT_SEED, pool.key().as_ref()], + bump, + )] + pub custody_vault: Box>, + + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = owner, + associated_token::token_program = token_program, + )] + pub trader_collateral: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/collect_fees.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/collect_fees.rs new file mode 100644 index 00000000..e97c6885 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/collect_fees.rs @@ -0,0 +1,82 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, +}; + +use crate::constants::{AUTHORITY_SEED, POOL_SEED, VAULT_SEED}; +use crate::errors::PerpError; +use crate::state::Pool; + +pub fn handle_collect_fees(context: Context) -> Result<()> { + let pool = &mut context.accounts.pool; + let amount = pool.protocol_fees; + require!(amount > 0, PerpError::NothingToClaim); + + // Effects before interaction: zero the balance, then transfer. + pool.protocol_fees = 0; + + let pool_key = pool.key(); + let authority_seeds: &[&[u8]] = &[AUTHORITY_SEED, pool_key.as_ref(), &[pool.authority_bump]]; + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.custody_vault.to_account_info(), + mint: context.accounts.collateral_mint.to_account_info(), + to: context.accounts.authority_collateral.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + &[authority_seeds], + ), + amount, + context.accounts.collateral_mint.decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct CollectFeesAccountConstraints<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + has_one = authority, + has_one = collateral_mint, + has_one = custody_vault, + )] + pub pool: Box>, + + /// CHECK: PDA authority over the vault. + #[account( + seeds = [AUTHORITY_SEED, pool.key().as_ref()], + bump = pool.authority_bump, + )] + pub pool_authority: UncheckedAccount<'info>, + + pub collateral_mint: Box>, + + #[account( + mut, + seeds = [VAULT_SEED, pool.key().as_ref()], + bump, + )] + pub custody_vault: Box>, + + #[account( + init_if_needed, + payer = authority, + associated_token::mint = collateral_mint, + associated_token::authority = authority, + associated_token::token_program = token_program, + )] + pub authority_collateral: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} 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 new file mode 100644 index 00000000..5e36c791 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -0,0 +1,153 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{ + AUTHORITY_SEED, BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_LEVERAGE_CEILING, POOL_SEED, + VAULT_SEED, +}; +use crate::errors::PerpError; +use crate::state::Pool; + +/// Trading parameters set once at pool creation. Bundled into one struct so the +/// instruction signature stays readable. +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct PoolParameters { + /// Decimal places the oracle quotes its price in (e.g. 8). + pub oracle_scale: u32, + + /// Funding accrued per slot, in `FUNDING_PRECISION` units, charged to the + /// heavier side. + pub funding_rate_per_slot: u64, + + pub open_fee_bps: u16, + pub close_fee_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, +} + +pub fn handle_initialize_pool( + context: Context, + parameters: PoolParameters, +) -> Result<()> { + let denominator = BASIS_POINTS_DENOMINATOR as u16; + require!( + parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING, + PerpError::InvalidParameter + ); + require!( + parameters.open_fee_bps < denominator, + PerpError::InvalidParameter + ); + require!( + parameters.close_fee_bps < denominator, + PerpError::InvalidParameter + ); + require!( + parameters.liquidation_fee_bps < denominator, + PerpError::InvalidParameter + ); + // Maintenance margin must leave room above zero and below full notional; + // a position is liquidatable once equity drops to this fraction of size. + require!( + parameters.maintenance_margin_bps > 0 && parameters.maintenance_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 + ); + + let pool = &mut context.accounts.pool; + pool.authority = context.accounts.authority.key(); + pool.collateral_mint = context.accounts.collateral_mint.key(); + pool.oracle_feed = context.accounts.oracle_feed.key(); + pool.oracle_scale = parameters.oracle_scale; + pool.custody_vault = context.accounts.custody_vault.key(); + pool.lp_mint = context.accounts.lp_mint.key(); + pool.liquidity = 0; + pool.reserved_liquidity = 0; + pool.total_collateral = 0; + pool.protocol_fees = 0; + pool.long_size = 0; + pool.short_size = 0; + pool.long_size_scaled = 0; + pool.short_size_scaled = 0; + pool.cumulative_funding = 0; + pool.last_funding_slot = Clock::get()?.slot; + pool.funding_rate_per_slot = parameters.funding_rate_per_slot; + pool.open_fee_bps = parameters.open_fee_bps; + pool.close_fee_bps = parameters.close_fee_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.bump = context.bumps.pool; + pool.authority_bump = context.bumps.pool_authority; + + Ok(()) +} + +#[derive(Accounts)] +pub struct InitializePoolAccountConstraints<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + init, + payer = authority, + space = Pool::DISCRIMINATOR.len() + Pool::INIT_SPACE, + seeds = [POOL_SEED, collateral_mint.key().as_ref(), oracle_feed.key().as_ref()], + bump, + )] + pub pool: Box>, + + 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 Switchboard feed in production. + pub oracle_feed: UncheckedAccount<'info>, + + /// CHECK: PDA that owns the vault and the liquidity-provider mint. Holds no + /// data; used only to sign vault and mint CPIs. + #[account( + seeds = [AUTHORITY_SEED, pool.key().as_ref()], + bump, + )] + pub pool_authority: UncheckedAccount<'info>, + + #[account( + init, + payer = authority, + seeds = [LP_MINT_SEED, pool.key().as_ref()], + bump, + mint::decimals = collateral_mint.decimals, + mint::authority = pool_authority, + mint::token_program = token_program, + )] + pub lp_mint: Box>, + + #[account( + init, + payer = authority, + seeds = [VAULT_SEED, pool.key().as_ref()], + bump, + token::mint = collateral_mint, + token::authority = pool_authority, + token::token_program = token_program, + )] + pub custody_vault: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs new file mode 100644 index 00000000..4f8c3bec --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs @@ -0,0 +1,178 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, +}; + +use crate::constants::{AUTHORITY_SEED, POOL_SEED, POSITION_SEED, VAULT_SEED}; +use crate::errors::PerpError; +use crate::instructions::shared::{accrue_funding, basis_point_fee, settle_position}; +use crate::state::{Pool, Position}; + +pub fn handle_liquidate_position( + context: Context, +) -> Result<()> { + let price = crate::state::oracle::read_oracle_price( + &context.accounts.oracle_feed, + context.accounts.pool.oracle_scale, + context.accounts.pool.max_confidence_bps, + )?; + + let pool = &mut context.accounts.pool; + accrue_funding(pool, Clock::get()?.slot)?; + + let position = &context.accounts.position; + let position_size = position.size; + let settlement = settle_position(pool, position, price)?; + + // Release the position's reserved liquidity now that it is closing. + pool.reserved_liquidity = pool + .reserved_liquidity + .checked_sub(position_size) + .ok_or(PerpError::MathOverflow)?; + + // Liquidatable only once equity has fallen to or below the maintenance + // margin. A healthy position can only be closed by its owner. + let maintenance = basis_point_fee(position_size, pool.maintenance_margin_bps)?; + require!( + settlement.equity <= maintenance as i128, + PerpError::PositionHealthy + ); + + // The liquidator's reward comes out of whatever equity remains, capped so a + // position already past zero equity cannot pay out more than it has. + let remaining_equity: u64 = settlement + .equity + .max(0) + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + let liquidation_fee = basis_point_fee(position.size, pool.liquidation_fee_bps)?; + let liquidator_payout = liquidation_fee.min(remaining_equity); + let trader_refund = remaining_equity + .checked_sub(liquidator_payout) + .ok_or(PerpError::MathOverflow)?; + + // Everything the trader does not get back stays with the liquidity + // providers. Derived from vault conservation: the pool keeps the position's + // collateral minus whatever is paid out as equity. + let liquidity_delta = (position.collateral as i128) + .checked_sub(remaining_equity as i128) + .ok_or(PerpError::MathOverflow)?; + let new_liquidity = (pool.liquidity as i128) + .checked_add(liquidity_delta) + .ok_or(PerpError::MathOverflow)?; + require!(new_liquidity >= 0, PerpError::PoolInsolvent); + pool.liquidity = new_liquidity + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + + let pool_key = pool.key(); + let authority_seeds: &[&[u8]] = &[AUTHORITY_SEED, pool_key.as_ref(), &[pool.authority_bump]]; + + if liquidator_payout > 0 { + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.custody_vault.to_account_info(), + mint: context.accounts.collateral_mint.to_account_info(), + to: context.accounts.liquidator_collateral.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + &[authority_seeds], + ), + liquidator_payout, + context.accounts.collateral_mint.decimals, + )?; + } + + if trader_refund > 0 { + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.custody_vault.to_account_info(), + mint: context.accounts.collateral_mint.to_account_info(), + to: context.accounts.trader_collateral.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + &[authority_seeds], + ), + trader_refund, + context.accounts.collateral_mint.decimals, + )?; + } + + Ok(()) +} + +#[derive(Accounts)] +pub struct LiquidatePositionAccountConstraints<'info> { + #[account(mut)] + pub liquidator: Signer<'info>, + + /// CHECK: the position owner, validated by the position's `has_one = owner`. + /// Receives the position account's rent and any equity refund. + #[account(mut)] + pub owner: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + has_one = collateral_mint, + has_one = custody_vault, + has_one = oracle_feed, + )] + pub pool: Box>, + + #[account( + mut, + close = owner, + seeds = [POSITION_SEED, pool.key().as_ref(), owner.key().as_ref(), position.side.as_seed()], + bump = position.bump, + has_one = owner, + has_one = pool, + )] + pub position: Box>, + + /// CHECK: PDA authority over the vault. + #[account( + seeds = [AUTHORITY_SEED, pool.key().as_ref()], + bump = pool.authority_bump, + )] + pub pool_authority: UncheckedAccount<'info>, + + /// CHECK: validated by the `has_one = oracle_feed` constraint on the pool. + pub oracle_feed: UncheckedAccount<'info>, + + pub collateral_mint: Box>, + + #[account( + mut, + seeds = [VAULT_SEED, pool.key().as_ref()], + bump, + )] + pub custody_vault: Box>, + + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = owner, + associated_token::token_program = token_program, + )] + pub trader_collateral: Box>, + + #[account( + init_if_needed, + payer = liquidator, + associated_token::mint = collateral_mint, + associated_token::authority = liquidator, + associated_token::token_program = token_program, + )] + pub liquidator_collateral: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} 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 new file mode 100644 index 00000000..88337e1d --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs @@ -0,0 +1,16 @@ +pub mod add_liquidity; +pub mod close_position; +pub mod collect_fees; +pub mod initialize_pool; +pub mod liquidate_position; +pub mod open_position; +pub mod remove_liquidity; +pub mod shared; + +pub use add_liquidity::*; +pub use close_position::*; +pub use collect_fees::*; +pub use initialize_pool::*; +pub use liquidate_position::*; +pub use open_position::*; +pub use remove_liquidity::*; 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 new file mode 100644 index 00000000..75772864 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs @@ -0,0 +1,182 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, +}; + +use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; +use crate::errors::PerpError; +use crate::instructions::shared::{accrue_funding, basis_point_fee, scale_size}; +use crate::state::{Pool, Position, Side}; + +pub fn handle_open_position( + context: Context, + side: Side, + collateral_amount: u64, + size: u64, + acceptable_price: u64, +) -> Result<()> { + require!(collateral_amount > 0 && size > 0, PerpError::ZeroAmount); + + let price = crate::state::oracle::read_oracle_price( + &context.accounts.oracle_feed, + context.accounts.pool.oracle_scale, + context.accounts.pool.max_confidence_bps, + )?; + + // Slippage: a long must not fill above the caller's limit, a short not + // below it. `0` opts out. + if acceptable_price != 0 { + let acceptable = match side { + Side::Long => price <= acceptable_price, + Side::Short => price >= acceptable_price, + }; + require!(acceptable, PerpError::SlippageExceeded); + } + + let pool = &mut context.accounts.pool; + accrue_funding(pool, Clock::get()?.slot)?; + + // The open fee is taken out of the posted collateral; the rest backs the + // position. Leverage and margin are measured against this net collateral. + let open_fee = basis_point_fee(size, pool.open_fee_bps)?; + let net_collateral = collateral_amount + .checked_sub(open_fee) + .ok_or(PerpError::InsufficientLiquidity)?; + require!(net_collateral > 0, PerpError::ZeroAmount); + + let max_notional = (net_collateral as u128) + .checked_mul(pool.max_leverage 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_point_fee(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 + // capital, which also caps total open interest at the pool's liquidity. + let new_reserved = pool + .reserved_liquidity + .checked_add(size) + .ok_or(PerpError::MathOverflow)?; + require!( + new_reserved <= pool.liquidity, + PerpError::InsufficientLiquidity + ); + pool.reserved_liquidity = new_reserved; + + let size_scaled = scale_size(size, price)?; + + // Effects: record the position and the pool's new aggregates before moving + // any tokens. + let position = &mut context.accounts.position; + position.owner = context.accounts.owner.key(); + position.pool = pool.key(); + position.side = side; + position.collateral = net_collateral; + position.size = size; + position.entry_price = price; + position.size_scaled = size_scaled; + position.entry_funding = pool.cumulative_funding; + position.bump = context.bumps.position; + + pool.total_collateral = pool + .total_collateral + .checked_add(net_collateral) + .ok_or(PerpError::MathOverflow)?; + pool.protocol_fees = pool + .protocol_fees + .checked_add(open_fee) + .ok_or(PerpError::MathOverflow)?; + + match side { + Side::Long => { + pool.long_size = pool + .long_size + .checked_add(size as u128) + .ok_or(PerpError::MathOverflow)?; + pool.long_size_scaled = pool + .long_size_scaled + .checked_add(size_scaled) + .ok_or(PerpError::MathOverflow)?; + } + Side::Short => { + pool.short_size = pool + .short_size + .checked_add(size as u128) + .ok_or(PerpError::MathOverflow)?; + pool.short_size_scaled = pool + .short_size_scaled + .checked_add(size_scaled) + .ok_or(PerpError::MathOverflow)?; + } + } + + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.trader_collateral.to_account_info(), + mint: context.accounts.collateral_mint.to_account_info(), + to: context.accounts.custody_vault.to_account_info(), + authority: context.accounts.owner.to_account_info(), + }, + ), + collateral_amount, + context.accounts.collateral_mint.decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +#[instruction(side: Side)] +pub struct OpenPositionAccountConstraints<'info> { + #[account(mut)] + pub owner: Signer<'info>, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + has_one = collateral_mint, + has_one = custody_vault, + has_one = oracle_feed, + )] + pub pool: Box>, + + #[account( + init, + payer = owner, + space = Position::DISCRIMINATOR.len() + Position::INIT_SPACE, + seeds = [POSITION_SEED, pool.key().as_ref(), owner.key().as_ref(), side.as_seed()], + bump, + )] + pub position: Box>, + + /// CHECK: validated by the `has_one = oracle_feed` constraint on the pool. + pub oracle_feed: UncheckedAccount<'info>, + + pub collateral_mint: Box>, + + #[account( + mut, + seeds = [VAULT_SEED, pool.key().as_ref()], + bump, + )] + pub custody_vault: Box>, + + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = owner, + associated_token::token_program = token_program, + )] + pub trader_collateral: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} 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 new file mode 100644 index 00000000..9d73f84d --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -0,0 +1,154 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{ + burn, transfer_checked, Burn, Mint, TokenAccount, TokenInterface, TransferChecked, + }, +}; + +use crate::constants::{AUTHORITY_SEED, POOL_SEED, VAULT_SEED}; +use crate::errors::PerpError; +use crate::instructions::shared::{accrue_funding, liquidity_provider_aum}; +use crate::state::Pool; + +pub fn handle_remove_liquidity( + context: Context, + shares: u64, + minimum_amount_out: u64, +) -> Result<()> { + require!(shares > 0, PerpError::ZeroAmount); + + let price = crate::state::oracle::read_oracle_price( + &context.accounts.oracle_feed, + context.accounts.pool.oracle_scale, + context.accounts.pool.max_confidence_bps, + )?; + + let pool = &mut context.accounts.pool; + accrue_funding(pool, Clock::get()?.slot)?; + + let lp_supply = context.accounts.lp_mint.supply; + let aum = liquidity_provider_aum(pool, price)?; + require!(aum > 0, PerpError::PoolInsolvent); + + // amount_out = shares * assets-under-management / supply, floored. + let amount_out: u64 = (shares as u128) + .checked_mul(aum as u128) + .ok_or(PerpError::MathOverflow)? + .checked_div(lp_supply as u128) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + + require!(amount_out > 0, PerpError::AmountRoundsToZero); + // Only free liquidity can leave: the portion reserved to cover open + // positions' payouts stays put, so a winning trader can always be paid. A + // provider wanting more must wait for positions to close. + let free_liquidity = pool + .liquidity + .checked_sub(pool.reserved_liquidity) + .ok_or(PerpError::MathOverflow)?; + require!( + amount_out <= free_liquidity, + PerpError::InsufficientLiquidity + ); + require!( + amount_out >= minimum_amount_out, + PerpError::SlippageExceeded + ); + + pool.liquidity = pool + .liquidity + .checked_sub(amount_out) + .ok_or(PerpError::MathOverflow)?; + + burn( + CpiContext::new( + context.accounts.token_program.key(), + Burn { + mint: context.accounts.lp_mint.to_account_info(), + from: context.accounts.provider_lp.to_account_info(), + authority: context.accounts.provider.to_account_info(), + }, + ), + shares, + )?; + + let pool_key = pool.key(); + let authority_seeds: &[&[u8]] = &[AUTHORITY_SEED, pool_key.as_ref(), &[pool.authority_bump]]; + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.custody_vault.to_account_info(), + mint: context.accounts.collateral_mint.to_account_info(), + to: context.accounts.provider_collateral.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + &[authority_seeds], + ), + amount_out, + context.accounts.collateral_mint.decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct RemoveLiquidityAccountConstraints<'info> { + #[account(mut)] + pub provider: Signer<'info>, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + has_one = collateral_mint, + has_one = lp_mint, + has_one = custody_vault, + has_one = oracle_feed, + )] + pub pool: Box>, + + /// CHECK: PDA authority over the vault and liquidity-provider mint. + #[account( + seeds = [AUTHORITY_SEED, pool.key().as_ref()], + bump = pool.authority_bump, + )] + pub pool_authority: UncheckedAccount<'info>, + + /// CHECK: validated by the `has_one = oracle_feed` constraint on the pool. + pub oracle_feed: UncheckedAccount<'info>, + + pub collateral_mint: Box>, + + #[account(mut)] + pub lp_mint: Box>, + + #[account( + mut, + seeds = [VAULT_SEED, pool.key().as_ref()], + bump, + )] + pub custody_vault: Box>, + + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = provider, + associated_token::token_program = token_program, + )] + pub provider_collateral: Box>, + + #[account( + mut, + associated_token::mint = lp_mint, + associated_token::authority = provider, + associated_token::token_program = token_program, + )] + pub provider_lp: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} 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 new file mode 100644 index 00000000..95ecf973 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs @@ -0,0 +1,205 @@ +use anchor_lang::prelude::*; + +use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; +use crate::errors::PerpError; +use crate::state::{Pool, Position, Side}; + +/// Result of removing a position from the pool's aggregates. All figures are in +/// collateral base units; `equity` is what the trader's position is worth +/// before any close or liquidation fee. +pub struct Settlement { + pub profit_and_loss: i128, + pub funding: i128, + pub equity: i128, +} + +/// Settle a position against the current `price`: compute its profit/loss, +/// funding owed, and equity, then remove its open interest and collateral from +/// the pool's aggregates. Does not touch `pool.liquidity` or move tokens — the +/// caller applies the side that differs between closing and liquidating. +pub fn settle_position(pool: &mut Pool, position: &Position, price: u64) -> Result { + let profit_and_loss = position_pnl(position.side, position.size, position.entry_price, price)?; + let funding = position_funding( + position.side, + position.size, + position.entry_funding, + pool.cumulative_funding, + )?; + + let equity = (position.collateral as i128) + .checked_add(profit_and_loss) + .ok_or(PerpError::MathOverflow)? + .checked_sub(funding) + .ok_or(PerpError::MathOverflow)?; + + match position.side { + Side::Long => { + pool.long_size = pool + .long_size + .checked_sub(position.size as u128) + .ok_or(PerpError::MathOverflow)?; + pool.long_size_scaled = pool + .long_size_scaled + .checked_sub(position.size_scaled) + .ok_or(PerpError::MathOverflow)?; + } + Side::Short => { + pool.short_size = pool + .short_size + .checked_sub(position.size as u128) + .ok_or(PerpError::MathOverflow)?; + pool.short_size_scaled = pool + .short_size_scaled + .checked_sub(position.size_scaled) + .ok_or(PerpError::MathOverflow)?; + } + } + + pool.total_collateral = pool + .total_collateral + .checked_sub(position.collateral) + .ok_or(PerpError::MathOverflow)?; + + Ok(Settlement { + profit_and_loss, + funding, + equity, + }) +} + +/// Advance the pool's cumulative funding index to `current_slot`. +/// +/// The heavier open-interest side pays funding to the pool: while longs are +/// larger the index rises (longs owe), while shorts are larger it falls (shorts +/// owe). No positions means no one to charge, so the index is left untouched and +/// only the timestamp moves forward. +pub fn accrue_funding(pool: &mut Pool, current_slot: u64) -> Result<()> { + let elapsed = current_slot.saturating_sub(pool.last_funding_slot); + if elapsed == 0 { + return Ok(()); + } + + if pool.long_size != 0 || pool.short_size != 0 { + let magnitude = (pool.funding_rate_per_slot as i128) + .checked_mul(elapsed as i128) + .ok_or(PerpError::MathOverflow)?; + let delta = if pool.long_size >= pool.short_size { + magnitude + } else { + -magnitude + }; + pool.cumulative_funding = pool + .cumulative_funding + .checked_add(delta) + .ok_or(PerpError::MathOverflow)?; + } + + pool.last_funding_slot = current_slot; + Ok(()) +} + +/// A position's contribution to the pool's `*_size_scaled` accumulator. +/// `entry_price` is always positive (oracle prices are validated `> 0`). +pub fn scale_size(size: u64, entry_price: u64) -> Result { + (size as u128) + .checked_mul(SIZE_PRECISION) + .ok_or(PerpError::MathOverflow)? + .checked_div(entry_price as u128) + .ok_or(PerpError::MathOverflow.into()) +} + +/// Signed profit/loss of one position at `price`, in collateral base units. +/// Longs profit when price rises, shorts when it falls. +pub fn position_pnl(side: Side, size: u64, entry_price: u64, price: u64) -> Result { + let size = size as i128; + let entry = entry_price as i128; + let price = price as i128; + + let price_change = match side { + Side::Long => price.checked_sub(entry), + Side::Short => entry.checked_sub(price), + } + .ok_or(PerpError::MathOverflow)?; + + // Multiply before dividing to keep precision; `entry > 0` is guaranteed. + size.checked_mul(price_change) + .ok_or(PerpError::MathOverflow)? + .checked_div(entry) + .ok_or(PerpError::MathOverflow.into()) +} + +/// Aggregate unrealized profit/loss of every open trader at `price`, derived +/// from the pool's running accumulators rather than iterating positions. +/// Positive means traders are collectively up (and the pool is down). +pub fn traders_unrealized_pnl(pool: &Pool, price: u64) -> Result { + let price = price as i128; + let size_precision = SIZE_PRECISION as i128; + + let long_value = price + .checked_mul(pool.long_size_scaled as i128) + .ok_or(PerpError::MathOverflow)? + .checked_div(size_precision) + .ok_or(PerpError::MathOverflow)?; + let long_pnl = long_value + .checked_sub(pool.long_size as i128) + .ok_or(PerpError::MathOverflow)?; + + let short_value = price + .checked_mul(pool.short_size_scaled as i128) + .ok_or(PerpError::MathOverflow)? + .checked_div(size_precision) + .ok_or(PerpError::MathOverflow)?; + let short_pnl = (pool.short_size as i128) + .checked_sub(short_value) + .ok_or(PerpError::MathOverflow)?; + + long_pnl + .checked_add(short_pnl) + .ok_or(PerpError::MathOverflow.into()) +} + +/// Liquidity-provider assets-under-management at `price`: pool liquidity minus +/// what traders are collectively owed. This is what liquidity-provider shares +/// are priced against, so it marks open positions to the current price and an +/// exiting provider cannot dodge an in-progress trader profit. +pub fn liquidity_provider_aum(pool: &Pool, price: u64) -> Result { + let traders = traders_unrealized_pnl(pool, price)?; + (pool.liquidity as i128) + .checked_sub(traders) + .ok_or(PerpError::MathOverflow.into()) +} + +/// Funding a position owes since it opened, in collateral base units. Positive +/// means the trader pays the pool; negative means the pool pays the trader. +pub fn position_funding( + side: Side, + size: u64, + entry_funding: i128, + pool_funding: i128, +) -> Result { + let funding_change = pool_funding + .checked_sub(entry_funding) + .ok_or(PerpError::MathOverflow)?; + let long_owed = (size as i128) + .checked_mul(funding_change) + .ok_or(PerpError::MathOverflow)? + .checked_div(FUNDING_PRECISION) + .ok_or(PerpError::MathOverflow)?; + + Ok(match side { + Side::Long => long_owed, + Side::Short => -long_owed, + }) +} + +/// Fee on `notional`, in basis points, rounded down. Widened to `u128` so a +/// large notional cannot overflow the intermediate product. +pub fn basis_point_fee(notional: u64, basis_points: u16) -> Result { + (notional as u128) + .checked_mul(basis_points as u128) + .ok_or(PerpError::MathOverflow)? + .checked_div(BASIS_POINTS_DENOMINATOR as u128) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow.into()) +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs new file mode 100644 index 00000000..31e19e4c --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs @@ -0,0 +1,82 @@ +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. +pub mod instructions; +pub mod state; + +use instructions::*; +use state::Side; + +declare_id!("3uCm8Jep469pHUpYQCh6eA6dpYV3ogvTvaRDZBPtw5So"); + +#[program] +pub mod perpetual_futures { + use super::*; + + /// Create a perpetual-futures pool for one collateral token, priced by one + /// oracle feed. Sets the trading parameters and creates the custody vault + /// and liquidity-provider mint. + pub fn initialize_pool( + context: Context, + parameters: PoolParameters, + ) -> Result<()> { + instructions::handle_initialize_pool(context, parameters) + } + + /// Deposit collateral into the pool and receive liquidity-provider shares. + /// `minimum_shares_out` is slippage protection; pass `0` to opt out. + pub fn add_liquidity( + context: Context, + amount: u64, + minimum_shares_out: u64, + ) -> Result<()> { + instructions::handle_add_liquidity(context, amount, minimum_shares_out) + } + + /// Burn liquidity-provider shares and withdraw the matching collateral. + /// `minimum_amount_out` is slippage protection; pass `0` to opt out. + pub fn remove_liquidity( + context: Context, + shares: u64, + minimum_amount_out: u64, + ) -> Result<()> { + instructions::handle_remove_liquidity(context, shares, minimum_amount_out) + } + + /// Open a leveraged long or short position against the pool at the current + /// oracle price. `acceptable_price` bounds the fill (longs reject above it, + /// shorts reject below it); pass `0` to opt out. + pub fn open_position( + context: Context, + side: Side, + collateral_amount: u64, + size: u64, + acceptable_price: u64, + ) -> Result<()> { + instructions::handle_open_position(context, side, collateral_amount, size, acceptable_price) + } + + /// Close the caller's own position, settling profit/loss, accrued funding, + /// and the close fee. `minimum_payout` is slippage protection; pass `0` to + /// opt out. + pub fn close_position( + context: Context, + minimum_payout: u64, + ) -> Result<()> { + instructions::handle_close_position(context, minimum_payout) + } + + /// Permissionlessly close a position whose equity has fallen to or below + /// the maintenance margin. The caller earns the liquidation fee. + pub fn liquidate_position(context: Context) -> Result<()> { + instructions::handle_liquidate_position(context) + } + + /// Pool authority sweeps the accumulated protocol fees from the vault. + pub fn collect_fees(context: Context) -> Result<()> { + instructions::handle_collect_fees(context) + } +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/mod.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/mod.rs new file mode 100644 index 00000000..be729118 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/mod.rs @@ -0,0 +1,6 @@ +pub mod oracle; +pub mod pool; +pub mod position; + +pub use pool::*; +pub use position::*; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs new file mode 100644 index 00000000..454e482d --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs @@ -0,0 +1,94 @@ +use anchor_lang::prelude::*; + +use crate::constants::{BASIS_POINTS_DENOMINATOR, MAX_PRICE_STALENESS_SLOTS}; +use crate::errors::PerpError; + +// Byte layout of the feed account this program reads. It matches the +// `mock_switchboard::MockFeed` account: an 8-byte Anchor discriminator followed +// by `authority: Pubkey (32)`, `price: i128 (16)`, `scale: u32 (4)`, +// `last_update_slot: u64 (8)`, `confidence: u64 (8)`. +// +// We read the raw bytes rather than deserializing the mock account type so this +// program stays decoupled from the mock. To consume a real Switchboard +// On-Demand feed, replace the offsets below with a call to +// `switchboard_on_demand::PullFeedAccountData::parse_and_verify(...)`, which +// also checks the Ed25519 signatures over the price update — the only other +// change is the feed account's owning program ID. +// +// A real feed reports a value plus a `confidence` band (a standard-deviation-like +// uncertainty). This reader rejects a price whose band is too wide relative to +// the price — skipping that check is the most common oracle footgun. Production +// venues (see solana-labs/perpetuals) often also use the feed's EMA rather than +// the spot price for a less manipulable mark; the mock omits the EMA to stay +// minimal. +const PRICE_OFFSET: usize = 8 + 32; +const SCALE_OFFSET: usize = PRICE_OFFSET + 16; +const LAST_UPDATE_SLOT_OFFSET: usize = SCALE_OFFSET + 4; +const CONFIDENCE_OFFSET: usize = LAST_UPDATE_SLOT_OFFSET + 8; +const FEED_MINIMUM_LENGTH: usize = CONFIDENCE_OFFSET + 8; + +/// Read and validate the oracle price from `feed`. +/// +/// Returns the price as a `u64` in the pool's `expected_scale` fixed point. +/// Rejects a stale price (older than `MAX_PRICE_STALENESS_SLOTS`), a +/// non-positive price, a feed whose scale differs from the pool's pinned scale, +/// and a price whose confidence band exceeds `max_confidence_bps` of the price. +pub fn read_oracle_price( + feed: &AccountInfo, + expected_scale: u32, + max_confidence_bps: u16, +) -> Result { + let data = feed.try_borrow_data()?; + require!( + data.len() >= FEED_MINIMUM_LENGTH, + PerpError::OracleDataTooShort + ); + + let price = i128::from_le_bytes( + data[PRICE_OFFSET..PRICE_OFFSET + 16] + .try_into() + .map_err(|_| PerpError::OracleDataTooShort)?, + ); + let scale = u32::from_le_bytes( + data[SCALE_OFFSET..SCALE_OFFSET + 4] + .try_into() + .map_err(|_| PerpError::OracleDataTooShort)?, + ); + let last_update_slot = u64::from_le_bytes( + data[LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8] + .try_into() + .map_err(|_| PerpError::OracleDataTooShort)?, + ); + let confidence = u64::from_le_bytes( + data[CONFIDENCE_OFFSET..CONFIDENCE_OFFSET + 8] + .try_into() + .map_err(|_| PerpError::OracleDataTooShort)?, + ); + + require!(price > 0, PerpError::NonPositivePrice); + require_eq!(scale, expected_scale, PerpError::OracleScaleMismatch); + + // `saturating_sub` floors the age at zero, so a feed slot momentarily ahead + // of the local clock reads as fresh rather than wrapping to a huge age. + let current_slot = Clock::get()?.slot; + require!( + current_slot.saturating_sub(last_update_slot) <= MAX_PRICE_STALENESS_SLOTS, + PerpError::StalePrice + ); + + // Reject an untrustworthy price: confidence band as a fraction of price, + // in basis points, must not exceed the pool's limit. Widen to u128 so the + // product cannot overflow, and `price > 0` is already guaranteed. + let confidence_bps = (confidence as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) + .ok_or(PerpError::MathOverflow)? + .checked_div(price as u128) + .ok_or(PerpError::MathOverflow)?; + require!( + confidence_bps <= max_confidence_bps as u128, + PerpError::OracleConfidenceTooWide + ); + + let price: u64 = price.try_into().map_err(|_| PerpError::MathOverflow)?; + Ok(price) +} 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 new file mode 100644 index 00000000..eae178c5 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs @@ -0,0 +1,99 @@ +use anchor_lang::prelude::*; + +/// One perpetual-futures market: a single collateral token priced by a single +/// oracle feed. Liquidity providers fund the pool and are the counterparty to +/// every trader; the pool absorbs trader profit and loss. +/// +/// Money fields are raw base units of the collateral token. The pool never +/// assumes decimals — `transfer_checked` carries them through every CPI. +#[account] +#[derive(InitSpace)] +pub struct Pool { + /// Admin: configures the pool and sweeps protocol fees. Not a custody + /// escape hatch — it cannot touch liquidity-provider or trader funds. + pub authority: Pubkey, + + pub collateral_mint: Pubkey, + + /// Oracle feed this market reads its price from. Stored so handlers can + /// reject any substituted feed account. + pub oracle_feed: Pubkey, + + /// Decimal places the oracle price is quoted in. Pinned at creation so a + /// feed that silently changes scale is rejected rather than mis-read. + pub oracle_scale: u32, + + pub custody_vault: Pubkey, + + pub lp_mint: Pubkey, + + /// Liquidity-provider-owned assets, in collateral base units. Grows with + /// deposits, trader losses, fees-to-LPs; shrinks with withdrawals and + /// trader profits. Trader collateral is tracked separately in + /// `total_collateral` and is not part of this figure. + pub liquidity: u64, + + /// Portion of `liquidity` reserved to cover open positions' maximum + /// recoverable profit (one notional `size` per position). Liquidity-provider + /// withdrawals can only take the free remainder (`liquidity - reserved`), so + /// a winning trader can always be paid. Also caps total exposure: a position + /// can only open while `reserved + size <= liquidity`. + pub reserved_liquidity: u64, + + /// Sum of every open position's posted collateral, held in the same vault. + pub total_collateral: u64, + + /// Protocol fees accrued from open/close fees, awaiting `collect_fees`. + pub protocol_fees: u64, + + /// Aggregate long open interest (sum of position `size`), in collateral + /// base units of notional. + pub long_size: u128, + + pub short_size: u128, + + /// Running sum of `size * SIZE_PRECISION / entry_price` for each side. + /// Lets mark-to-market assets-under-management be derived from the current + /// price without iterating positions: aggregate long profit/loss equals + /// `price * long_size_scaled / SIZE_PRECISION - long_size`. + pub long_size_scaled: u128, + + pub short_size_scaled: u128, + + /// Cumulative funding index, scaled by `FUNDING_PRECISION`. Rises while + /// longs are the heavier side (longs pay), falls while shorts are heavier. + /// A position pays funding proportional to the change in this index between + /// open and close. + pub cumulative_funding: i128, + + pub last_funding_slot: u64, + + /// Funding accrued per slot, in `FUNDING_PRECISION` units, applied to the + /// heavier side. The funding paid by traders accrues to the pool. + pub funding_rate_per_slot: u64, + + /// Fee charged on notional when opening a position, in basis points. + pub open_fee_bps: u16, + + pub close_fee_bps: u16, + + /// Highest leverage a position may open at (`size <= collateral * max`). + pub max_leverage: u16, + + /// 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. + pub liquidation_fee_bps: u16, + + /// Maximum oracle confidence band, in basis points of the price, that the + /// pool will trade against. A wider band is rejected as untrustworthy. + pub max_confidence_bps: u16, + + pub bump: u8, + + /// Bump for the vault/LP-mint authority PDA, stored so CPIs can sign without + /// re-deriving it. + pub authority_bump: u8, +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/position.rs new file mode 100644 index 00000000..29a0d708 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/position.rs @@ -0,0 +1,52 @@ +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, InitSpace, Clone, Copy, PartialEq, Eq, Debug)] +pub enum Side { + Long, + Short, +} + +impl Side { + /// Seed fragment used in the position PDA, so one owner can hold a long and + /// a short in the same pool simultaneously. Returns a `'static` slice so it + /// can be used directly in the `seeds` constraint without a temporary. + pub fn as_seed(&self) -> &'static [u8] { + match self { + Side::Long => b"long", + Side::Short => b"short", + } + } +} + +/// A single trader's leveraged position. One PDA per (pool, owner, side). +#[account] +#[derive(InitSpace)] +pub struct Position { + pub owner: Pubkey, + + pub pool: Pubkey, + + pub side: Side, + + /// Collateral the trader posted, in collateral base units. Part of the + /// pool's `total_collateral` while the position is open. + pub collateral: u64, + + /// Notional position size, in collateral base units. `size / collateral` is + /// the leverage. + pub size: u64, + + /// Oracle price at open, in the pool's `oracle_scale` fixed point. Always + /// positive, so stored unsigned. + pub entry_price: u64, + + /// This position's contribution to the pool's `*_size_scaled` accumulator + /// (`size * SIZE_PRECISION / entry_price`). Stored so it can be subtracted + /// exactly on close without recomputing and re-rounding. + pub size_scaled: u128, + + /// Pool `cumulative_funding` at open. Funding owed is the change since. + pub entry_funding: i128, + + pub bump: u8, +} 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 new file mode 100644 index 00000000..f9efa6ac --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -0,0 +1,1027 @@ +use { + anchor_lang::{ + solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, + AccountDeserialize, InstructionData, ToAccountMetas, + }, + litesvm::LiteSVM, + perpetual_futures::{instructions::initialize_pool::PoolParameters, state::Pool, state::Side}, + solana_keypair::Keypair, + solana_kite::{ + create_associated_token_account, create_token_mint, create_wallet, + get_token_account_balance, mint_tokens_to_token_account, + send_transaction_from_instructions, + }, + solana_signer::Signer, +}; + +// Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 +// base units. +const ONE_USDC: u64 = 1_000_000; +const DECIMALS: u8 = 6; + +// The oracle quotes prices with 8 decimals, so $100 is 100 * 10^8. +const ORACLE_SCALE: u32 = 8; + +fn token_program_id() -> Pubkey { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() +} + +fn ata_program_id() -> Pubkey { + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + .parse() + .unwrap() +} + +fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()], + &ata_program_id(), + ) + .0 +} + +/// Oracle price for a whole-dollar amount, in the feed's fixed point. +fn dollars(whole: i128) -> i128 { + whole * 10i128.pow(ORACLE_SCALE) +} + +/// One deployed market plus the keys needed to drive it. +struct Market { + svm: LiteSVM, + payer: Keypair, + admin: Keypair, + collateral_mint: Pubkey, + feed: Pubkey, + pool: Pubkey, + pool_authority: Pubkey, + lp_mint: Pubkey, + custody_vault: Pubkey, +} + +impl Market { + /// Stand up a market with the given starting oracle price and per-slot + /// funding rate. The admin is both the pool authority and the oracle feed + /// authority. + fn new(initial_price: i128, funding_rate_per_slot: u64) -> Market { + let mut svm = LiteSVM::new(); + svm.add_program( + perpetual_futures::id(), + include_bytes!("../../../target/deploy/perpetual_futures.so"), + ) + .unwrap(); + svm.add_program( + mock_switchboard::id(), + include_bytes!("../../../target/deploy/mock_switchboard.so"), + ) + .unwrap(); + + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let admin = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let collateral_mint = create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap(); + + // Create the mock oracle feed as a fresh account owned by the mock + // program; the admin is its update authority. + let feed_keypair = Keypair::new(); + let initialize_feed = Instruction::new_with_bytes( + mock_switchboard::id(), + &mock_switchboard::instruction::InitializeFeed { + price: initial_price, + scale: ORACLE_SCALE, + confidence: 0, + } + .data(), + mock_switchboard::accounts::InitializeFeedAccountConstraints { + feed: feed_keypair.pubkey(), + authority: admin.pubkey(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut svm, + vec![initialize_feed], + &[&admin, &feed_keypair], + &admin.pubkey(), + ) + .unwrap(); + let feed = feed_keypair.pubkey(); + + let pool = Pubkey::find_program_address( + &[b"pool", collateral_mint.as_ref(), feed.as_ref()], + &perpetual_futures::id(), + ) + .0; + let pool_authority = + Pubkey::find_program_address(&[b"authority", pool.as_ref()], &perpetual_futures::id()) + .0; + let lp_mint = + Pubkey::find_program_address(&[b"lp_mint", pool.as_ref()], &perpetual_futures::id()).0; + let custody_vault = + Pubkey::find_program_address(&[b"vault", pool.as_ref()], &perpetual_futures::id()).0; + + let parameters = PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_slot, + open_fee_bps: 10, + close_fee_bps: 10, + max_leverage: 10, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + }; + let initialize_pool = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::InitializePool { parameters }.data(), + perpetual_futures::accounts::InitializePoolAccountConstraints { + authority: admin.pubkey(), + pool, + collateral_mint, + oracle_feed: feed, + pool_authority, + lp_mint, + custody_vault, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut svm, + vec![initialize_pool], + &[&admin], + &admin.pubkey(), + ) + .unwrap(); + + Market { + svm, + payer, + admin, + collateral_mint, + feed, + pool, + pool_authority, + lp_mint, + custody_vault, + } + } + + fn default_market() -> Market { + // Funding off by default so profit/loss assertions are exact. + Market::new(dollars(100), 0) + } + + fn pool_state(&self) -> Pool { + let account = self.svm.get_account(&self.pool).unwrap(); + Pool::try_deserialize(&mut account.data.as_slice()).unwrap() + } + + fn set_price(&mut self, price: i128) { + self.set_price_with_confidence(price, 0); + } + + fn set_price_with_confidence(&mut self, price: i128, confidence: u64) { + let set_price = Instruction::new_with_bytes( + mock_switchboard::id(), + &mock_switchboard::instruction::SetPrice { price, confidence }.data(), + mock_switchboard::accounts::SetPriceAccountConstraints { + feed: self.feed, + authority: self.admin.pubkey(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![set_price], + &[&self.admin], + &self.admin.pubkey(), + ) + .unwrap(); + } + + fn warp(&mut self, slot: u64) { + self.svm.warp_to_slot(slot); + self.svm.expire_blockhash(); + } + + /// Create a wallet holding `amount` collateral tokens in its associated + /// token account. + fn funded_trader(&mut self, amount: u64) -> (Keypair, Pubkey) { + let trader = create_wallet(&mut self.svm, 100_000_000_000).unwrap(); + let token_account = create_associated_token_account( + &mut self.svm, + &trader.pubkey(), + &self.collateral_mint, + &self.payer, + ) + .unwrap(); + mint_tokens_to_token_account( + &mut self.svm, + &self.collateral_mint, + &token_account, + amount, + &self.admin, + ) + .unwrap(); + (trader, token_account) + } + + fn add_liquidity( + &mut self, + provider: &Keypair, + provider_collateral: Pubkey, + amount: u64, + minimum_shares_out: u64, + ) -> Result<(), ()> { + let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::AddLiquidity { + amount, + minimum_shares_out, + } + .data(), + perpetual_futures::accounts::AddLiquidityAccountConstraints { + provider: provider.pubkey(), + pool: self.pool, + pool_authority: self.pool_authority, + oracle_feed: self.feed, + collateral_mint: self.collateral_mint, + lp_mint: self.lp_mint, + custody_vault: self.custody_vault, + provider_collateral, + provider_lp, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[provider], + &provider.pubkey(), + ) + .map(|_| ()) + .map_err(|_| ()) + } + + fn remove_liquidity( + &mut self, + provider: &Keypair, + provider_collateral: Pubkey, + shares: u64, + minimum_amount_out: u64, + ) -> Result<(), ()> { + let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::RemoveLiquidity { + shares, + minimum_amount_out, + } + .data(), + perpetual_futures::accounts::RemoveLiquidityAccountConstraints { + provider: provider.pubkey(), + pool: self.pool, + pool_authority: self.pool_authority, + oracle_feed: self.feed, + collateral_mint: self.collateral_mint, + lp_mint: self.lp_mint, + custody_vault: self.custody_vault, + provider_collateral, + provider_lp, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[provider], + &provider.pubkey(), + ) + .map(|_| ()) + .map_err(|_| ()) + } + + fn position_pda(&self, owner: &Pubkey, side: Side) -> Pubkey { + let side_seed: &[u8] = match side { + Side::Long => b"long", + Side::Short => b"short", + }; + Pubkey::find_program_address( + &[b"position", self.pool.as_ref(), owner.as_ref(), side_seed], + &perpetual_futures::id(), + ) + .0 + } + + fn open_position( + &mut self, + trader: &Keypair, + trader_collateral: Pubkey, + side: Side, + collateral_amount: u64, + size: u64, + acceptable_price: u64, + ) -> Result<(), ()> { + let position = self.position_pda(&trader.pubkey(), side); + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::OpenPosition { + side, + collateral_amount, + size, + acceptable_price, + } + .data(), + perpetual_futures::accounts::OpenPositionAccountConstraints { + owner: trader.pubkey(), + pool: self.pool, + position, + oracle_feed: self.feed, + collateral_mint: self.collateral_mint, + custody_vault: self.custody_vault, + trader_collateral, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[trader], + &trader.pubkey(), + ) + .map(|_| ()) + .map_err(|_| ()) + } + + fn close_position( + &mut self, + trader: &Keypair, + trader_collateral: Pubkey, + side: Side, + minimum_payout: u64, + ) -> Result<(), ()> { + let position = self.position_pda(&trader.pubkey(), side); + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::ClosePosition { minimum_payout }.data(), + perpetual_futures::accounts::ClosePositionAccountConstraints { + owner: trader.pubkey(), + pool: self.pool, + position, + pool_authority: self.pool_authority, + oracle_feed: self.feed, + collateral_mint: self.collateral_mint, + custody_vault: self.custody_vault, + trader_collateral, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[trader], + &trader.pubkey(), + ) + .map(|_| ()) + .map_err(|_| ()) + } + + fn liquidate( + &mut self, + liquidator: &Keypair, + owner: &Pubkey, + owner_collateral: Pubkey, + side: Side, + ) -> Result<(), ()> { + let position = self.position_pda(owner, side); + let liquidator_collateral = derive_ata(&liquidator.pubkey(), &self.collateral_mint); + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::LiquidatePosition {}.data(), + perpetual_futures::accounts::LiquidatePositionAccountConstraints { + liquidator: liquidator.pubkey(), + owner: *owner, + pool: self.pool, + position, + pool_authority: self.pool_authority, + oracle_feed: self.feed, + collateral_mint: self.collateral_mint, + custody_vault: self.custody_vault, + trader_collateral: owner_collateral, + liquidator_collateral, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[liquidator], + &liquidator.pubkey(), + ) + .map(|_| ()) + .map_err(|_| ()) + } + + 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(), + &perpetual_futures::instruction::CollectFees {}.data(), + perpetual_futures::accounts::CollectFeesAccountConstraints { + authority: authority.pubkey(), + pool: self.pool, + pool_authority: self.pool_authority, + collateral_mint: self.collateral_mint, + custody_vault: self.custody_vault, + authority_collateral, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[authority], + &authority.pubkey(), + ) + .map(|_| ()) + .map_err(|_| ()) + } + + /// Deposit a large amount of liquidity so the pool can pay trader profits, + /// returning the provider and its collateral account. + fn seed_liquidity(&mut self, amount: u64) -> (Keypair, Pubkey) { + let (provider, provider_collateral) = self.funded_trader(amount); + self.add_liquidity(&provider, provider_collateral, amount, 0) + .unwrap(); + (provider, provider_collateral) + } +} + +#[test] +fn test_initialize_pool() { + let market = Market::default_market(); + let pool = market.pool_state(); + + assert_eq!(pool.authority, market.admin.pubkey()); + 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.liquidity, 0); + assert_eq!(pool.total_collateral, 0); +} + +#[test] +fn test_add_liquidity_first_deposit_withholds_minimum() { + let mut market = Market::default_market(); + let deposit = 10_000 * ONE_USDC; + let (provider, provider_collateral) = market.funded_trader(deposit); + + market + .add_liquidity(&provider, provider_collateral, deposit, 0) + .unwrap(); + + // The pool holds the full deposit; the provider's shares are the deposit + // minus the withheld minimum. + assert_eq!(market.pool_state().liquidity, deposit); + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + assert_eq!(shares, deposit - 1_000); +} + +#[test] +fn test_first_deposit_below_minimum_fails() { + let mut market = Market::default_market(); + let (provider, provider_collateral) = market.funded_trader(10_000); + // 500 base units is below the 1_000 locked minimum. + assert!(market + .add_liquidity(&provider, provider_collateral, 500, 0) + .is_err()); +} + +#[test] +fn test_add_liquidity_subsequent_is_proportional() { + let mut market = Market::default_market(); + let first = 10_000 * ONE_USDC; + market.seed_liquidity(first); + + // With no open positions and an unchanged price, assets-under-management + // equals liquidity, so a second equal deposit mints ~the same shares. + let second = 10_000 * ONE_USDC; + let (provider, provider_collateral) = market.funded_trader(second); + market + .add_liquidity(&provider, provider_collateral, second, 0) + .unwrap(); + + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + // supply before second deposit was `first - 1_000`; second shares = + // second * supply / aum = second * (first - 1_000) / first. + let expected = ((second as u128) * ((first - 1_000) as u128) / (first as u128)) as u64; + assert_eq!(shares, expected); +} + +#[test] +fn test_add_and_remove_liquidity_round_trip() { + let mut market = Market::default_market(); + let deposit = 10_000 * ONE_USDC; + let (provider, provider_collateral) = market.funded_trader(deposit); + market + .add_liquidity(&provider, provider_collateral, deposit, 0) + .unwrap(); + + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + market + .remove_liquidity(&provider, provider_collateral, shares, 0) + .unwrap(); + + // As the only liquidity provider, they reclaim the full deposit: their + // shares carry the whole pool, since the withheld minimum was never minted + // to anyone else to hold it back. + let returned = get_token_account_balance(&market.svm, &provider_collateral).unwrap(); + assert_eq!(returned, deposit); + assert_eq!(market.pool_state().liquidity, 0); +} + +#[test] +fn test_open_long_updates_pool() { + 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(); + + let pool = market.pool_state(); + assert_eq!(pool.long_size, size as u128); + assert_eq!(pool.short_size, 0); + // Collateral minus the 0.1% open fee is now tracked as trader collateral. + let open_fee = size / 1_000; + assert_eq!(pool.total_collateral, collateral - open_fee); + assert_eq!(pool.protocol_fees, open_fee); +} + +#[test] +fn test_close_long_in_profit() { + 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(); + + // Price rises 20%: a $5,000 long earns $1,000. + market.set_price(dollars(120)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let profit = size / 5; // 20% of notional + let expected_payout = net_collateral + profit - close_fee; + let balance = get_token_account_balance(&market.svm, &trader_collateral).unwrap(); + assert_eq!(balance, expected_payout); +} + +#[test] +fn test_close_long_in_loss() { + 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(); + + let liquidity_before = market.pool_state().liquidity; + + // Price falls 10%: a $5,000 long loses $500. + market.set_price(dollars(90)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let loss = size / 10; // 10% of notional + let expected_payout = net_collateral - loss - close_fee; + let balance = get_token_account_balance(&market.svm, &trader_collateral).unwrap(); + assert_eq!(balance, expected_payout); + + // The trader's loss accrued to the liquidity providers. + assert_eq!(market.pool_state().liquidity, liquidity_before + loss); +} + +#[test] +fn test_close_short_in_profit() { + 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::Short, collateral, size, 0) + .unwrap(); + + // Price falls 10%: a $5,000 short earns $500. + market.set_price(dollars(90)); + market + .close_position(&trader, trader_collateral, Side::Short, 0) + .unwrap(); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let profit = size / 10; + let expected_payout = net_collateral + profit - close_fee; + let balance = get_token_account_balance(&market.svm, &trader_collateral).unwrap(); + assert_eq!(balance, expected_payout); +} + +#[test] +fn test_open_rejects_zero_amounts() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); + + assert!(market + .open_position( + &trader, + trader_collateral, + Side::Long, + 0, + 5_000 * ONE_USDC, + 0 + ) + .is_err()); + assert!(market + .open_position( + &trader, + trader_collateral, + Side::Long, + 1_000 * ONE_USDC, + 0, + 0 + ) + .is_err()); +} + +#[test] +fn test_open_rejects_excess_leverage() { + 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); + + // 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] +fn test_open_long_slippage_guard() { + 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); + + // Current price is $100 (10^10 in scale 8). A long willing to pay at most + // $99 must be rejected. + let acceptable_price = (dollars(99)) as u64; + assert!(market + .open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + acceptable_price + ) + .is_err()); +} + +#[test] +fn test_stale_price_rejected() { + 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); + + // Move far past the staleness window without refreshing the feed. + market.warp(10_000); + assert!(market + .open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + 0 + ) + .is_err()); +} + +#[test] +fn test_wide_oracle_confidence_rejected() { + 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); + + // The pool tolerates a 1% confidence band (max_confidence_bps = 100). Widen + // the feed's band to 2% of the price and the open must be rejected. + market.set_price_with_confidence(dollars(100), dollars(2) as u64); + assert!(market + .open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + 0 + ) + .is_err()); +} + +#[test] +fn test_funding_charged_to_long() { + // Funding on: longs are the only side, so they pay funding to the pool. + let mut market = Market::new(dollars(100), 5_000); + 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(); + + let liquidity_before = market.pool_state().liquidity; + + // Let funding accrue, then refresh the feed so the price is fresh again and + // close at the same price (no profit/loss). + market.warp(2_000); + market.set_price(dollars(100)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let payout = get_token_account_balance(&market.svm, &trader_collateral).unwrap(); + + // The trader received less than collateral-minus-close-fee; the shortfall + // is the funding they paid, which went to the liquidity providers. + assert!(payout < net_collateral - close_fee); + let funding_paid = (net_collateral - close_fee) - payout; + assert!(funding_paid > 0); + assert_eq!( + market.pool_state().liquidity, + liquidity_before + funding_paid + ); +} + +#[test] +fn test_liquidation_of_underwater_long() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + + // High leverage: a ~9x long, so a small adverse move erodes the margin. + // Collateral leaves room above the notional after the open fee (10,000 of + // notional needs at least 1,000 of net collateral at 10x). + 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(); + + // Price falls 9%: a $10,000 long loses $900, dropping equity below the 5% + // maintenance margin. + market.set_price(dollars(91)); + + let liquidator = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + let liquidator_collateral = create_associated_token_account( + &mut market.svm, + &liquidator.pubkey(), + &market.collateral_mint, + &market.payer, + ) + .unwrap(); + + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + + // The liquidator earned a fee and the position is gone. + let reward = get_token_account_balance(&market.svm, &liquidator_collateral).unwrap(); + assert!(reward > 0); + 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_healthy_position_cannot_be_liquidated() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + + let collateral = 1_000 * ONE_USDC; + let size = 2_000 * ONE_USDC; // 2x leverage, plenty of margin + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + let liquidator = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + create_associated_token_account( + &mut market.svm, + &liquidator.pubkey(), + &market.collateral_mint, + &market.payer, + ) + .unwrap(); + + // Price barely moves; the position stays healthy. + market.set_price(dollars(99)); + assert!(market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .is_err()); +} + +#[test] +fn test_collect_fees() { + 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(); + + let fees = market.pool_state().protocol_fees; + assert!(fees > 0); + + let admin = market.admin.insecure_clone(); + let admin_collateral = create_associated_token_account( + &mut market.svm, + &admin.pubkey(), + &market.collateral_mint, + &market.payer, + ) + .unwrap(); + market.collect_fees(&admin).unwrap(); + + assert_eq!( + get_token_account_balance(&market.svm, &admin_collateral).unwrap(), + fees + ); + assert_eq!(market.pool_state().protocol_fees, 0); + + // Nothing left to claim on a second sweep. + assert!(market.collect_fees(&admin).is_err()); +} + +#[test] +fn test_collect_fees_requires_authority() { + 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); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + 0, + ) + .unwrap(); + + let imposter = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + create_associated_token_account( + &mut market.svm, + &imposter.pubkey(), + &market.collateral_mint, + &market.payer, + ) + .unwrap(); + assert!(market.collect_fees(&imposter).is_err()); +} + +#[test] +fn test_open_rejects_when_pool_cannot_back_it() { + let mut market = Market::default_market(); + // Only 3,000 of liquidity, but a 5,000 position must reserve 5,000. + market.seed_liquidity(3_000 * ONE_USDC); + let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); + assert!(market + .open_position( + &trader, + trader_collateral, + Side::Long, + 1_000 * ONE_USDC, + 5_000 * ONE_USDC, + 0 + ) + .is_err()); +} + +#[test] +fn test_profit_capped_at_reserved_notional() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 2_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(); + + // Price triples: uncapped profit would be 2x the notional, but recoverable + // profit is capped at the reserved notional (`size`). + market.set_price(dollars(300)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let expected = net_collateral + size - close_fee; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + expected + ); +} + +#[test] +fn test_remove_liquidity_blocked_by_reserved() { + let mut market = Market::default_market(); + let (provider, provider_collateral) = market.seed_liquidity(10_000 * ONE_USDC); + let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + 1_000 * ONE_USDC, + 5_000 * ONE_USDC, + 0, + ) + .unwrap(); + + // 5,000 of the 10,000 liquidity is now reserved. Pulling everything fails, + // but withdrawing within the free half succeeds. + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + assert!(market + .remove_liquidity(&provider, provider_collateral, shares, 0) + .is_err()); + assert!(market + .remove_liquidity(&provider, provider_collateral, shares / 2, 0) + .is_ok()); +} diff --git a/finance/perpetual-futures/quasar/.gitignore b/finance/perpetual-futures/quasar/.gitignore new file mode 100644 index 00000000..3dcc0425 --- /dev/null +++ b/finance/perpetual-futures/quasar/.gitignore @@ -0,0 +1,5 @@ +target +**/*.rs.bk +node_modules +test-ledger +.DS_Store diff --git a/finance/perpetual-futures/quasar/Cargo.toml b/finance/perpetual-futures/quasar/Cargo.toml new file mode 100644 index 00000000..bd1036db --- /dev/null +++ b/finance/perpetual-futures/quasar/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "quasar-perpetual-futures" +version = "0.1.0" +edition = "2021" + +# Standalone workspace — not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', +] + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +alloc = [] +client = [] +debug = [] + +[dependencies] +# Pinned to the same revision the other Quasar examples use. quasar pin +# rationale: master HEAD currently fails to compile because zeropod 0.3.x +# auto-generates accessor methods that conflict with hand-written ones in +# quasar-spl. 623bb70 is the last working rev on master before that bump. +# Unpin (back to branch = "master") once upstream merges the fix. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +solana-instruction = { version = "3.2.0" } + +[dev-dependencies] +quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } +spl-token-interface = { version = "2.0.0" } +solana-program-pack = { version = "3.1.0" } diff --git a/finance/perpetual-futures/quasar/Quasar.toml b/finance/perpetual-futures/quasar/Quasar.toml new file mode 100644 index 00000000..e31f23af --- /dev/null +++ b/finance/perpetual-futures/quasar/Quasar.toml @@ -0,0 +1,22 @@ +[project] +name = "quasar_perpetual_futures" + +[toolchain] +type = "solana" + +[testing] +language = "rust" + +[testing.rust] +framework = "quasar-svm" + +[testing.rust.test] +program = "cargo" +args = [ + "test", + "tests::", +] + +[clients] +path = "target/client" +languages = ["rust"] diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md new file mode 100644 index 00000000..5c6a21a3 --- /dev/null +++ b/finance/perpetual-futures/quasar/README.md @@ -0,0 +1,36 @@ +# Perpetual Futures (Quasar) + +A [Quasar](https://quasar-lang.com/docs) port of the perpetual-futures example. +The design, math, and behaviour match the Anchor implementation at +[`../anchor`](../anchor) — read that README for the full walkthrough of the +oracle-priced, pool-collateralized model, the funding mechanism, and the money +math. This page only covers what differs in the Quasar version. + +## Differences from the Anchor version + +- **One position per trader per pool.** The Anchor version seeds the position + PDA by side (`[b"position", pool, owner, side]`) so a trader can hold a long + and a short at once. Quasar's `address` constraint can only reference account + inputs, not instruction arguments, so the side cannot be a seed; the position + PDA is `[b"position", pool, owner]` and the side is stored in the account. A + trader therefore holds a single open position per pool here. +- **Oracle feed in tests.** Rather than a separate mock-oracle program, the + tests write the feed account's bytes directly (price, scale, last-update slot) + and the program reads them the same way it would read a real Switchboard feed. +- **State writes** use Quasar's zero-copy field accessors (`field.get()` / + `field.set()`) and `set_inner`, rather than Anchor's `Account` mutation. + +## Testing + +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, liquidation, and fee collection. + +```bash +cargo build-sbf +cargo test tests:: +``` + +`cargo build-sbf` first, so the tests can load the compiled program from +`target/deploy/`. diff --git a/finance/perpetual-futures/quasar/src/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs new file mode 100644 index 00000000..1ff6012a --- /dev/null +++ b/finance/perpetual-futures/quasar/src/constants.rs @@ -0,0 +1,26 @@ +//! Shared constants. See the Anchor sibling for the prose explanations; the +//! values are identical so the two implementations behave the same. + +/// 100% expressed in basis points. +pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; + +/// Fixed-point precision for the cumulative funding index. +pub const FUNDING_PRECISION: i128 = 1_000_000_000; + +/// Fixed-point precision for the per-side `size / entry_price` accumulators. +pub const SIZE_PRECISION: u128 = 1_000_000_000; + +/// Liquidity-provider shares withheld from the first deposit so the share +/// supply never starts at a dust amount. +pub const MINIMUM_LIQUIDITY: u64 = 1_000; + +/// Reject an oracle price older than this many slots (~1 minute at 400ms). +pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; + +/// Upper bound on a pool's configurable `max_leverage`. +pub const MAX_LEVERAGE_CEILING: u16 = 100; + +/// Long / short discriminants, used both as the position-PDA seed byte and the +/// `side` instruction argument. +pub const SIDE_LONG: u8 = 0; +pub const SIDE_SHORT: u8 = 1; diff --git a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs new file mode 100644 index 00000000..24fb5522 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs @@ -0,0 +1,150 @@ +use { + crate::{ + constants::MINIMUM_LIQUIDITY, + instructions::shared::{ + advance_funding, err, error, read_oracle_price, traders_unrealized_pnl, + }, + state::Pool, + LpMintPda, PoolAuthorityPda, + }, + quasar_lang::{prelude::*, sysvars::clock::Clock}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct AddLiquidity { + #[account(mut)] + pub provider: Signer, + #[account( + mut, + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + has_one(custody_vault), + )] + pub pool: Account, + /// Authority PDA over the vault and liquidity-provider mint. + #[account(address = PoolAuthorityPda::seeds(pool.address()))] + pub pool_authority: UncheckedAccount, + /// CHECK: bound to the pool via its seeds (the pool PDA is derived from it). + pub oracle_feed: UncheckedAccount, + pub collateral_mint: Account, + #[account(mut, address = LpMintPda::seeds(pool.address()))] + pub lp_mint: InterfaceAccount, + #[account(mut)] + pub custody_vault: Account, + #[account(mut)] + pub provider_collateral: Account, + #[account( + mut, + init(idempotent), + payer = provider, + token(mint = lp_mint, authority = provider, token_program = token_program), + )] + pub provider_lp: Account, + pub token_program: Program, + pub system_program: Program, + pub clock: Sysvar, +} + +#[inline(always)] +pub fn handle_add_liquidity( + accounts: &mut AddLiquidity, + amount: u64, + minimum_shares_out: u64, + bumps: &AddLiquidityBumps, +) -> Result<(), ProgramError> { + if amount == 0 { + return Err(err(error::ZERO_AMOUNT)); + } + + let slot = accounts.clock.slot.get(); + let scale = accounts.pool.oracle_scale.get(); + let max_confidence_bps = accounts.pool.max_confidence_bps.get(); + let price = { + let view = accounts.oracle_feed.to_account_view(); + let data = view + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; + read_oracle_price(&data, scale, slot, max_confidence_bps)? + }; + + let new_funding = advance_funding( + accounts.pool.cumulative_funding.get(), + accounts.pool.last_funding_slot.get(), + slot, + accounts.pool.funding_rate_per_slot.get(), + accounts.pool.long_size.get(), + accounts.pool.short_size.get(), + )?; + accounts.pool.cumulative_funding.set(new_funding); + accounts.pool.last_funding_slot.set(slot); + + let lp_supply = accounts.lp_mint.supply(); + let shares: u64 = if lp_supply == 0 { + amount + .checked_sub(MINIMUM_LIQUIDITY) + .ok_or_else(|| err(error::DEPOSIT_TOO_SMALL))? + } else { + let traders = traders_unrealized_pnl( + accounts.pool.long_size.get(), + accounts.pool.long_size_scaled.get(), + accounts.pool.short_size.get(), + accounts.pool.short_size_scaled.get(), + price, + )?; + let aum = (accounts.pool.liquidity.get() as i128) + .checked_sub(traders) + .ok_or(ProgramError::ArithmeticOverflow)?; + if aum <= 0 { + return Err(err(error::POOL_INSOLVENT)); + } + let computed = (amount as u128) + .checked_mul(lp_supply as u128) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div(aum as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + u64::try_from(computed).map_err(|_| ProgramError::ArithmeticOverflow)? + }; + + if shares == 0 { + return Err(err(error::AMOUNT_ROUNDS_TO_ZERO)); + } + if shares < minimum_shares_out { + return Err(err(error::SLIPPAGE_EXCEEDED)); + } + + let new_liquidity = accounts + .pool + .liquidity + .get() + .checked_add(amount) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.liquidity.set(new_liquidity); + + accounts + .token_program + .transfer( + &accounts.provider_collateral, + &accounts.custody_vault, + &accounts.provider, + amount, + ) + .invoke()?; + + let bump = [bumps.pool_authority]; + let seeds: &[Seed] = &[ + Seed::from(b"authority".as_ref()), + Seed::from(accounts.pool.address().as_ref()), + Seed::from(&bump as &[u8]), + ]; + accounts + .token_program + .mint_to( + &accounts.lp_mint, + &accounts.provider_lp, + &accounts.pool_authority, + shares, + ) + .invoke_signed(seeds)?; + + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/instructions/close_position.rs b/finance/perpetual-futures/quasar/src/instructions/close_position.rs new file mode 100644 index 00000000..67355174 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/close_position.rs @@ -0,0 +1,199 @@ +use { + crate::{ + constants::SIDE_LONG, + instructions::shared::{ + advance_funding, basis_point_fee, err, error, position_funding, position_pnl, + read_oracle_price, + }, + state::{Pool, Position}, + PoolAuthorityPda, + }, + quasar_lang::{prelude::*, sysvars::clock::Clock}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct ClosePosition { + #[account(mut)] + pub owner: Signer, + #[account( + mut, + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + has_one(custody_vault), + )] + pub pool: Account, + #[account( + mut, + has_one(owner), + address = Position::seeds(pool.address(), owner.address()), + close(dest = owner), + )] + pub position: Account, + #[account(address = PoolAuthorityPda::seeds(pool.address()))] + pub pool_authority: UncheckedAccount, + /// CHECK: bound to the pool via its seeds. + pub oracle_feed: UncheckedAccount, + pub collateral_mint: Account, + #[account(mut)] + pub custody_vault: Account, + #[account(mut)] + pub trader_collateral: Account, + pub token_program: Program, + pub system_program: Program, + pub clock: Sysvar, +} + +#[inline(always)] +pub fn handle_close_position( + accounts: &mut ClosePosition, + minimum_payout: u64, + bumps: &ClosePositionBumps, +) -> Result<(), ProgramError> { + let slot = accounts.clock.slot.get(); + let scale = accounts.pool.oracle_scale.get(); + let max_confidence_bps = accounts.pool.max_confidence_bps.get(); + let price = { + let view = accounts.oracle_feed.to_account_view(); + let data = view + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; + read_oracle_price(&data, scale, slot, max_confidence_bps)? + }; + + let new_funding = advance_funding( + accounts.pool.cumulative_funding.get(), + accounts.pool.last_funding_slot.get(), + slot, + accounts.pool.funding_rate_per_slot.get(), + accounts.pool.long_size.get(), + accounts.pool.short_size.get(), + )?; + accounts.pool.cumulative_funding.set(new_funding); + accounts.pool.last_funding_slot.set(slot); + + let side = accounts.position.side; + let size = accounts.position.size.get(); + let entry_price = accounts.position.entry_price.get(); + let collateral = accounts.position.collateral.get(); + let size_scaled = accounts.position.size_scaled.get(); + let entry_funding = accounts.position.entry_funding.get(); + + let pnl = position_pnl(side, size, entry_price, price)?; + let funding = position_funding(side, size, entry_funding, new_funding)?; + // Recoverable profit is capped at the reserved amount (the notional `size`), + // so the pool can always cover a winner. Losses are not capped. + let realized_pnl = pnl.min(size as i128); + let equity = (collateral as i128) + .checked_add(realized_pnl) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_sub(funding) + .ok_or(ProgramError::ArithmeticOverflow)?; + + let close_fee = basis_point_fee(size, accounts.pool.close_fee_bps.get())?; + let payout = equity + .checked_sub(close_fee as i128) + .ok_or(ProgramError::ArithmeticOverflow)?; + if payout <= 0 { + return Err(err(error::POSITION_NOT_HEALTHY)); + } + let payout = u64::try_from(payout).map_err(|_| ProgramError::ArithmeticOverflow)?; + if payout < minimum_payout { + return Err(err(error::SLIPPAGE_EXCEEDED)); + } + + remove_open_interest(&mut accounts.pool, side, size, size_scaled)?; + + // Release the position's reserved liquidity now that it is closing. + let new_reserved = accounts + .pool + .reserved_liquidity + .get() + .checked_sub(size) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.reserved_liquidity.set(new_reserved); + + let new_total_collateral = accounts + .pool + .total_collateral + .get() + .checked_sub(collateral) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.total_collateral.set(new_total_collateral); + + let liquidity_delta = funding + .checked_sub(realized_pnl) + .ok_or(ProgramError::ArithmeticOverflow)?; + let new_liquidity = (accounts.pool.liquidity.get() as i128) + .checked_add(liquidity_delta) + .ok_or(ProgramError::ArithmeticOverflow)?; + if new_liquidity < 0 { + return Err(err(error::POOL_INSOLVENT)); + } + accounts + .pool + .liquidity + .set(u64::try_from(new_liquidity).map_err(|_| ProgramError::ArithmeticOverflow)?); + + let new_protocol_fees = accounts + .pool + .protocol_fees + .get() + .checked_add(close_fee) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.protocol_fees.set(new_protocol_fees); + + let bump = [bumps.pool_authority]; + let seeds: &[Seed] = &[ + Seed::from(b"authority".as_ref()), + Seed::from(accounts.pool.address().as_ref()), + Seed::from(&bump as &[u8]), + ]; + accounts + .token_program + .transfer( + &accounts.custody_vault, + &accounts.trader_collateral, + &accounts.pool_authority, + payout, + ) + .invoke_signed(seeds)?; + + Ok(()) +} + +/// Subtract a position's open interest from the pool's per-side accumulators. +pub fn remove_open_interest( + pool: &mut Account, + side: u8, + size: u64, + size_scaled: u128, +) -> Result<(), ProgramError> { + if side == SIDE_LONG { + let long_size = pool + .long_size + .get() + .checked_sub(size as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + pool.long_size.set(long_size); + let long_scaled = pool + .long_size_scaled + .get() + .checked_sub(size_scaled) + .ok_or(ProgramError::ArithmeticOverflow)?; + pool.long_size_scaled.set(long_scaled); + } else { + let short_size = pool + .short_size + .get() + .checked_sub(size as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + pool.short_size.set(short_size); + let short_scaled = pool + .short_size_scaled + .get() + .checked_sub(size_scaled) + .ok_or(ProgramError::ArithmeticOverflow)?; + pool.short_size_scaled.set(short_scaled); + } + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/instructions/collect_fees.rs b/finance/perpetual-futures/quasar/src/instructions/collect_fees.rs new file mode 100644 index 00000000..fd64e761 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/collect_fees.rs @@ -0,0 +1,68 @@ +use { + crate::{ + instructions::shared::{err, error}, + state::Pool, + PoolAuthorityPda, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct CollectFees { + #[account(mut)] + pub authority: Signer, + #[account( + mut, + has_one(authority), + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + has_one(custody_vault), + )] + pub pool: Account, + #[account(address = PoolAuthorityPda::seeds(pool.address()))] + pub pool_authority: UncheckedAccount, + /// CHECK: bound to the pool via its seeds. + pub oracle_feed: UncheckedAccount, + pub collateral_mint: Account, + #[account(mut)] + pub custody_vault: Account, + #[account( + mut, + init(idempotent), + payer = authority, + token(mint = collateral_mint, authority = authority, token_program = token_program), + )] + pub authority_collateral: Account, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_collect_fees( + accounts: &mut CollectFees, + bumps: &CollectFeesBumps, +) -> Result<(), ProgramError> { + let amount = accounts.pool.protocol_fees.get(); + if amount == 0 { + return Err(err(error::NOTHING_TO_CLAIM)); + } + accounts.pool.protocol_fees.set(0); + + let bump = [bumps.pool_authority]; + let seeds: &[Seed] = &[ + Seed::from(b"authority".as_ref()), + Seed::from(accounts.pool.address().as_ref()), + Seed::from(&bump as &[u8]), + ]; + accounts + .token_program + .transfer( + &accounts.custody_vault, + &accounts.authority_collateral, + &accounts.pool_authority, + amount, + ) + .invoke_signed(seeds)?; + + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs new file mode 100644 index 00000000..932ca67b --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs @@ -0,0 +1,111 @@ +use { + crate::{ + constants::{BASIS_POINTS_DENOMINATOR, MAX_LEVERAGE_CEILING}, + instructions::shared::{err, error}, + state::{Pool, PoolInner}, + LpMintPda, PoolAuthorityPda, VaultPda, + }, + quasar_lang::{prelude::*, sysvars::clock::Clock}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct InitializePool { + #[account(mut)] + pub authority: Signer, + #[account( + mut, + init, + payer = authority, + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + )] + pub pool: Account, + pub collateral_mint: Account, + /// CHECK: stored on the pool; every read validates layout, scale, freshness. + pub oracle_feed: UncheckedAccount, + /// Authority PDA over the vault and liquidity-provider mint. + #[account(address = PoolAuthorityPda::seeds(pool.address()))] + pub pool_authority: UncheckedAccount, + #[account( + mut, + init, + payer = authority, + address = LpMintPda::seeds(pool.address()), + mint(decimals = 6, authority = pool_authority, freeze_authority = None, token_program = token_program), + )] + pub lp_mint: Account, + #[account( + mut, + init(idempotent), + payer = authority, + address = VaultPda::seeds(pool.address()), + token(mint = collateral_mint, authority = pool_authority, token_program = token_program), + )] + pub custody_vault: Account, + pub token_program: Program, + pub system_program: Program, + pub clock: Sysvar, + pub rent: Sysvar, +} + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn handle_initialize_pool( + accounts: &mut InitializePool, + oracle_scale: u32, + funding_rate_per_slot: u64, + open_fee_bps: u16, + close_fee_bps: u16, + max_leverage: u16, + maintenance_margin_bps: u16, + liquidation_fee_bps: u16, + max_confidence_bps: u16, + bumps: &InitializePoolBumps, +) -> Result<(), ProgramError> { + let denominator = BASIS_POINTS_DENOMINATOR as u16; + 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 + { + return Err(err(error::INVALID_PARAMETER)); + } + if maintenance_margin_bps == 0 || maintenance_margin_bps >= denominator { + return Err(err(error::INVALID_PARAMETER)); + } + if max_confidence_bps == 0 || max_confidence_bps >= denominator { + return Err(err(error::INVALID_PARAMETER)); + } + + let slot = accounts.clock.slot.get(); + accounts.pool.set_inner(PoolInner { + authority: *accounts.authority.address(), + collateral_mint: *accounts.collateral_mint.address(), + oracle_feed: *accounts.oracle_feed.address(), + custody_vault: *accounts.custody_vault.address(), + lp_mint: *accounts.lp_mint.address(), + oracle_scale, + liquidity: 0, + reserved_liquidity: 0, + total_collateral: 0, + protocol_fees: 0, + long_size: 0, + short_size: 0, + long_size_scaled: 0, + short_size_scaled: 0, + cumulative_funding: 0, + last_funding_slot: slot, + funding_rate_per_slot, + open_fee_bps, + close_fee_bps, + max_leverage, + maintenance_margin_bps, + liquidation_fee_bps, + max_confidence_bps, + bump: bumps.pool, + authority_bump: bumps.pool_authority, + }); + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs new file mode 100644 index 00000000..a16be32d --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs @@ -0,0 +1,178 @@ +use { + crate::{ + instructions::{ + close_position::remove_open_interest, + shared::{ + advance_funding, basis_point_fee, err, error, position_funding, position_pnl, + read_oracle_price, + }, + }, + state::{Pool, Position}, + PoolAuthorityPda, + }, + quasar_lang::{prelude::*, sysvars::clock::Clock}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct LiquidatePosition { + #[account(mut)] + pub liquidator: Signer, + /// CHECK: the position owner; receives the rent refund and any equity left. + #[account(mut)] + pub owner: UncheckedAccount, + #[account( + mut, + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + has_one(custody_vault), + )] + pub pool: Account, + #[account( + mut, + has_one(owner), + address = Position::seeds(pool.address(), owner.address()), + close(dest = owner), + )] + pub position: Account, + #[account(address = PoolAuthorityPda::seeds(pool.address()))] + pub pool_authority: UncheckedAccount, + /// CHECK: bound to the pool via its seeds. + pub oracle_feed: UncheckedAccount, + pub collateral_mint: Account, + #[account(mut)] + pub custody_vault: Account, + #[account(mut)] + pub trader_collateral: Account, + #[account( + mut, + init(idempotent), + payer = liquidator, + token(mint = collateral_mint, authority = liquidator, token_program = token_program), + )] + pub liquidator_collateral: Account, + pub token_program: Program, + pub system_program: Program, + pub clock: Sysvar, +} + +#[inline(always)] +pub fn handle_liquidate_position( + accounts: &mut LiquidatePosition, + bumps: &LiquidatePositionBumps, +) -> Result<(), ProgramError> { + let slot = accounts.clock.slot.get(); + let scale = accounts.pool.oracle_scale.get(); + let max_confidence_bps = accounts.pool.max_confidence_bps.get(); + let price = { + let view = accounts.oracle_feed.to_account_view(); + let data = view + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; + read_oracle_price(&data, scale, slot, max_confidence_bps)? + }; + + let new_funding = advance_funding( + accounts.pool.cumulative_funding.get(), + accounts.pool.last_funding_slot.get(), + slot, + accounts.pool.funding_rate_per_slot.get(), + accounts.pool.long_size.get(), + accounts.pool.short_size.get(), + )?; + accounts.pool.cumulative_funding.set(new_funding); + accounts.pool.last_funding_slot.set(slot); + + let side = accounts.position.side; + let size = accounts.position.size.get(); + let entry_price = accounts.position.entry_price.get(); + let collateral = accounts.position.collateral.get(); + let size_scaled = accounts.position.size_scaled.get(); + let entry_funding = accounts.position.entry_funding.get(); + + let pnl = position_pnl(side, size, entry_price, price)?; + let funding = position_funding(side, size, entry_funding, new_funding)?; + let equity = (collateral as i128) + .checked_add(pnl) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_sub(funding) + .ok_or(ProgramError::ArithmeticOverflow)?; + + let maintenance = basis_point_fee(size, accounts.pool.maintenance_margin_bps.get())?; + if equity > maintenance as i128 { + return Err(err(error::POSITION_HEALTHY)); + } + + let remaining_equity = + u64::try_from(equity.max(0)).map_err(|_| ProgramError::ArithmeticOverflow)?; + let liquidation_fee = basis_point_fee(size, accounts.pool.liquidation_fee_bps.get())?; + let liquidator_payout = liquidation_fee.min(remaining_equity); + let trader_refund = remaining_equity + .checked_sub(liquidator_payout) + .ok_or(ProgramError::ArithmeticOverflow)?; + + remove_open_interest(&mut accounts.pool, side, size, size_scaled)?; + + // Release the position's reserved liquidity now that it is closing. + let new_reserved = accounts + .pool + .reserved_liquidity + .get() + .checked_sub(size) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.reserved_liquidity.set(new_reserved); + + let new_total_collateral = accounts + .pool + .total_collateral + .get() + .checked_sub(collateral) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.total_collateral.set(new_total_collateral); + + // The pool keeps the position's collateral minus whatever equity is paid out. + let liquidity_delta = (collateral as i128) + .checked_sub(remaining_equity as i128) + .ok_or(ProgramError::ArithmeticOverflow)?; + let new_liquidity = (accounts.pool.liquidity.get() as i128) + .checked_add(liquidity_delta) + .ok_or(ProgramError::ArithmeticOverflow)?; + if new_liquidity < 0 { + return Err(err(error::POOL_INSOLVENT)); + } + accounts + .pool + .liquidity + .set(u64::try_from(new_liquidity).map_err(|_| ProgramError::ArithmeticOverflow)?); + + let bump = [bumps.pool_authority]; + let seeds: &[Seed] = &[ + Seed::from(b"authority".as_ref()), + Seed::from(accounts.pool.address().as_ref()), + Seed::from(&bump as &[u8]), + ]; + + if liquidator_payout > 0 { + accounts + .token_program + .transfer( + &accounts.custody_vault, + &accounts.liquidator_collateral, + &accounts.pool_authority, + liquidator_payout, + ) + .invoke_signed(seeds)?; + } + if trader_refund > 0 { + accounts + .token_program + .transfer( + &accounts.custody_vault, + &accounts.trader_collateral, + &accounts.pool_authority, + trader_refund, + ) + .invoke_signed(seeds)?; + } + + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/instructions/mod.rs b/finance/perpetual-futures/quasar/src/instructions/mod.rs new file mode 100644 index 00000000..00453e62 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/mod.rs @@ -0,0 +1,16 @@ +mod add_liquidity; +mod close_position; +mod collect_fees; +mod initialize_pool; +mod liquidate_position; +mod open_position; +mod remove_liquidity; +pub mod shared; + +pub use add_liquidity::*; +pub use close_position::*; +pub use collect_fees::*; +pub use initialize_pool::*; +pub use liquidate_position::*; +pub use open_position::*; +pub use remove_liquidity::*; diff --git a/finance/perpetual-futures/quasar/src/instructions/open_position.rs b/finance/perpetual-futures/quasar/src/instructions/open_position.rs new file mode 100644 index 00000000..62af4cc4 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/open_position.rs @@ -0,0 +1,199 @@ +use { + crate::{ + constants::{SIDE_LONG, SIDE_SHORT}, + instructions::shared::{ + advance_funding, basis_point_fee, err, error, read_oracle_price, scale_size, + }, + state::{Pool, Position, PositionInner}, + }, + quasar_lang::{prelude::*, sysvars::clock::Clock}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct OpenPosition { + #[account(mut)] + pub owner: Signer, + #[account( + mut, + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + has_one(custody_vault), + )] + pub pool: Account, + #[account( + mut, + init, + payer = owner, + address = Position::seeds(pool.address(), owner.address()), + )] + pub position: Account, + /// CHECK: bound to the pool via its seeds. + pub oracle_feed: UncheckedAccount, + pub collateral_mint: Account, + #[account(mut)] + pub custody_vault: Account, + #[account(mut)] + pub trader_collateral: Account, + pub token_program: Program, + pub system_program: Program, + pub clock: Sysvar, + pub rent: Sysvar, +} + +#[inline(always)] +pub fn handle_open_position( + accounts: &mut OpenPosition, + side: u8, + collateral_amount: u64, + size: u64, + acceptable_price: u64, + bumps: &OpenPositionBumps, +) -> Result<(), ProgramError> { + if side != SIDE_LONG && side != SIDE_SHORT { + return Err(err(error::INVALID_PARAMETER)); + } + if collateral_amount == 0 || size == 0 { + return Err(err(error::ZERO_AMOUNT)); + } + + let slot = accounts.clock.slot.get(); + let scale = accounts.pool.oracle_scale.get(); + let max_confidence_bps = accounts.pool.max_confidence_bps.get(); + let price = { + let view = accounts.oracle_feed.to_account_view(); + let data = view + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; + read_oracle_price(&data, scale, slot, max_confidence_bps)? + }; + + if acceptable_price != 0 { + let acceptable = if side == SIDE_LONG { + price <= acceptable_price + } else { + price >= acceptable_price + }; + if !acceptable { + return Err(err(error::SLIPPAGE_EXCEEDED)); + } + } + + let new_funding = advance_funding( + accounts.pool.cumulative_funding.get(), + accounts.pool.last_funding_slot.get(), + slot, + accounts.pool.funding_rate_per_slot.get(), + accounts.pool.long_size.get(), + accounts.pool.short_size.get(), + )?; + accounts.pool.cumulative_funding.set(new_funding); + accounts.pool.last_funding_slot.set(slot); + + let open_fee = basis_point_fee(size, accounts.pool.open_fee_bps.get())?; + let net_collateral = collateral_amount + .checked_sub(open_fee) + .ok_or_else(|| err(error::INSUFFICIENT_LIQUIDITY))?; + 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) + .ok_or(ProgramError::ArithmeticOverflow)?; + if size as u128 > max_notional { + return Err(err(error::LEVERAGE_TOO_HIGH)); + } + + let maintenance = basis_point_fee(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 + // (its notional `size`), backed by liquidity-provider capital. This also + // caps total open interest at the pool's liquidity. + let new_reserved = accounts + .pool + .reserved_liquidity + .get() + .checked_add(size) + .ok_or_else(|| ProgramError::ArithmeticOverflow)?; + if new_reserved > accounts.pool.liquidity.get() { + return Err(err(error::INSUFFICIENT_LIQUIDITY)); + } + accounts.pool.reserved_liquidity.set(new_reserved); + + let size_scaled = scale_size(size, price)?; + + accounts.position.set_inner(PositionInner { + owner: *accounts.owner.address(), + pool: *accounts.pool.address(), + side, + collateral: net_collateral, + size, + entry_price: price, + size_scaled, + entry_funding: new_funding, + bump: bumps.position, + }); + + let new_total_collateral = accounts + .pool + .total_collateral + .get() + .checked_add(net_collateral) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.total_collateral.set(new_total_collateral); + + let new_protocol_fees = accounts + .pool + .protocol_fees + .get() + .checked_add(open_fee) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.protocol_fees.set(new_protocol_fees); + + if side == SIDE_LONG { + let long_size = accounts + .pool + .long_size + .get() + .checked_add(size as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.long_size.set(long_size); + let long_scaled = accounts + .pool + .long_size_scaled + .get() + .checked_add(size_scaled) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.long_size_scaled.set(long_scaled); + } else { + let short_size = accounts + .pool + .short_size + .get() + .checked_add(size as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.short_size.set(short_size); + let short_scaled = accounts + .pool + .short_size_scaled + .get() + .checked_add(size_scaled) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.short_size_scaled.set(short_scaled); + } + + accounts + .token_program + .transfer( + &accounts.trader_collateral, + &accounts.custody_vault, + &accounts.owner, + collateral_amount, + ) + .invoke()?; + + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs new file mode 100644 index 00000000..884e718e --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs @@ -0,0 +1,153 @@ +use { + crate::{ + instructions::shared::{ + advance_funding, err, error, read_oracle_price, traders_unrealized_pnl, + }, + state::Pool, + LpMintPda, PoolAuthorityPda, + }, + quasar_lang::{prelude::*, sysvars::clock::Clock}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct RemoveLiquidity { + #[account(mut)] + pub provider: Signer, + #[account( + mut, + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + has_one(custody_vault), + )] + pub pool: Account, + #[account(address = PoolAuthorityPda::seeds(pool.address()))] + pub pool_authority: UncheckedAccount, + /// CHECK: bound to the pool via its seeds. + pub oracle_feed: UncheckedAccount, + pub collateral_mint: Account, + #[account(mut, address = LpMintPda::seeds(pool.address()))] + pub lp_mint: InterfaceAccount, + #[account(mut)] + pub custody_vault: Account, + #[account( + mut, + init(idempotent), + payer = provider, + token(mint = collateral_mint, authority = provider, token_program = token_program), + )] + pub provider_collateral: Account, + #[account(mut)] + pub provider_lp: Account, + pub token_program: Program, + pub system_program: Program, + pub clock: Sysvar, +} + +#[inline(always)] +pub fn handle_remove_liquidity( + accounts: &mut RemoveLiquidity, + shares: u64, + minimum_amount_out: u64, + bumps: &RemoveLiquidityBumps, +) -> Result<(), ProgramError> { + if shares == 0 { + return Err(err(error::ZERO_AMOUNT)); + } + + let slot = accounts.clock.slot.get(); + let scale = accounts.pool.oracle_scale.get(); + let max_confidence_bps = accounts.pool.max_confidence_bps.get(); + let price = { + let view = accounts.oracle_feed.to_account_view(); + let data = view + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; + read_oracle_price(&data, scale, slot, max_confidence_bps)? + }; + + let new_funding = advance_funding( + accounts.pool.cumulative_funding.get(), + accounts.pool.last_funding_slot.get(), + slot, + accounts.pool.funding_rate_per_slot.get(), + accounts.pool.long_size.get(), + accounts.pool.short_size.get(), + )?; + accounts.pool.cumulative_funding.set(new_funding); + accounts.pool.last_funding_slot.set(slot); + + let lp_supply = accounts.lp_mint.supply(); + let traders = traders_unrealized_pnl( + accounts.pool.long_size.get(), + accounts.pool.long_size_scaled.get(), + accounts.pool.short_size.get(), + accounts.pool.short_size_scaled.get(), + price, + )?; + let aum = (accounts.pool.liquidity.get() as i128) + .checked_sub(traders) + .ok_or(ProgramError::ArithmeticOverflow)?; + if aum <= 0 { + return Err(err(error::POOL_INSOLVENT)); + } + + let amount_out = (shares as u128) + .checked_mul(aum as u128) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div(lp_supply as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + let amount_out = u64::try_from(amount_out).map_err(|_| ProgramError::ArithmeticOverflow)?; + + if amount_out == 0 { + return Err(err(error::AMOUNT_ROUNDS_TO_ZERO)); + } + // Only free liquidity can leave; the reserved portion backs open positions. + let free_liquidity = accounts + .pool + .liquidity + .get() + .checked_sub(accounts.pool.reserved_liquidity.get()) + .ok_or(ProgramError::ArithmeticOverflow)?; + if amount_out > free_liquidity { + return Err(err(error::INSUFFICIENT_LIQUIDITY)); + } + if amount_out < minimum_amount_out { + return Err(err(error::SLIPPAGE_EXCEEDED)); + } + + let new_liquidity = accounts + .pool + .liquidity + .get() + .checked_sub(amount_out) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.liquidity.set(new_liquidity); + + accounts + .token_program + .burn( + &accounts.provider_lp, + &accounts.lp_mint, + &accounts.provider, + shares, + ) + .invoke()?; + + let bump = [bumps.pool_authority]; + let seeds: &[Seed] = &[ + Seed::from(b"authority".as_ref()), + Seed::from(accounts.pool.address().as_ref()), + Seed::from(&bump as &[u8]), + ]; + accounts + .token_program + .transfer( + &accounts.custody_vault, + &accounts.provider_collateral, + &accounts.pool_authority, + amount_out, + ) + .invoke_signed(seeds)?; + + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs new file mode 100644 index 00000000..bc303f11 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -0,0 +1,227 @@ +//! Money math and the oracle decode, ported verbatim from the Anchor sibling. +//! All integer, all `checked_*`, multiply-before-divide, rounding toward the +//! protocol. Errors are `ProgramError::Custom(code)`; the codes are listed here. + +use quasar_lang::prelude::*; + +use crate::constants::{ + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, MAX_PRICE_STALENESS_SLOTS, SIDE_LONG, + SIZE_PRECISION, +}; + +pub mod error { + pub const ZERO_AMOUNT: u32 = 0; + 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; + pub const ORACLE_SCALE_MISMATCH: u32 = 6; + pub const ORACLE_DATA_TOO_SHORT: u32 = 7; + pub const SLIPPAGE_EXCEEDED: u32 = 8; + pub const INSUFFICIENT_LIQUIDITY: u32 = 9; + pub const POOL_INSOLVENT: u32 = 10; + pub const POSITION_HEALTHY: u32 = 11; + pub const POSITION_NOT_HEALTHY: u32 = 12; + pub const NOTHING_TO_CLAIM: u32 = 13; + pub const DEPOSIT_TOO_SMALL: u32 = 14; + pub const AMOUNT_ROUNDS_TO_ZERO: u32 = 15; + pub const ORACLE_CONFIDENCE_TOO_WIDE: u32 = 16; +} + +#[inline(always)] +pub fn err(code: u32) -> ProgramError { + ProgramError::Custom(code) +} + +#[inline(always)] +fn overflow() -> ProgramError { + ProgramError::ArithmeticOverflow +} + +// Byte layout of the oracle feed account: price (i128), scale (u32), +// last_update_slot (u64), confidence (u64). The tests craft this directly; in +// production it would be a real Switchboard On-Demand feed parsed with signature +// verification. +// +// Like the Anchor sibling, this validates freshness, positivity, and the +// confidence band (`confidence / price`), rejecting a price whose band is too +// wide. A production reader may also prefer the feed's EMA over the spot price; +// the mock omits the EMA to stay minimal. +const PRICE_OFFSET: usize = 0; +const SCALE_OFFSET: usize = PRICE_OFFSET + 16; +const LAST_UPDATE_SLOT_OFFSET: usize = SCALE_OFFSET + 4; +const CONFIDENCE_OFFSET: usize = LAST_UPDATE_SLOT_OFFSET + 8; +const FEED_MINIMUM_LENGTH: usize = CONFIDENCE_OFFSET + 8; + +/// Read and validate the oracle price from raw feed bytes. Returns the price as +/// a `u64` in `expected_scale` fixed point. Rejects a price whose confidence +/// band exceeds `max_confidence_bps` of the price. +pub fn read_oracle_price( + data: &[u8], + expected_scale: u32, + current_slot: u64, + max_confidence_bps: u16, +) -> Result { + if data.len() < FEED_MINIMUM_LENGTH { + return Err(err(error::ORACLE_DATA_TOO_SHORT)); + } + + let price = i128::from_le_bytes( + data[PRICE_OFFSET..PRICE_OFFSET + 16] + .try_into() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?, + ); + let scale = u32::from_le_bytes( + data[SCALE_OFFSET..SCALE_OFFSET + 4] + .try_into() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?, + ); + let last_update_slot = u64::from_le_bytes( + data[LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8] + .try_into() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?, + ); + let confidence = u64::from_le_bytes( + data[CONFIDENCE_OFFSET..CONFIDENCE_OFFSET + 8] + .try_into() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?, + ); + + if price <= 0 { + return Err(err(error::NON_POSITIVE_PRICE)); + } + if scale != expected_scale { + return Err(err(error::ORACLE_SCALE_MISMATCH)); + } + if current_slot.saturating_sub(last_update_slot) > MAX_PRICE_STALENESS_SLOTS { + return Err(err(error::STALE_PRICE)); + } + + // Confidence band as a fraction of price, in basis points, must stay within + // the pool's limit. Widen to u128 so the product cannot overflow. + let confidence_bps = (confidence as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) + .ok_or_else(overflow)? + .checked_div(price as u128) + .ok_or_else(overflow)?; + if confidence_bps > max_confidence_bps as u128 { + return Err(err(error::ORACLE_CONFIDENCE_TOO_WIDE)); + } + + u64::try_from(price).map_err(|_| overflow()) +} + +/// New cumulative funding index after advancing to `current_slot`. The heavier +/// side pays: the index rises while longs lead, falls while shorts lead. +pub fn advance_funding( + cumulative_funding: i128, + last_funding_slot: u64, + current_slot: u64, + funding_rate_per_slot: u64, + long_size: u128, + short_size: u128, +) -> Result { + let elapsed = current_slot.saturating_sub(last_funding_slot); + if elapsed == 0 || (long_size == 0 && short_size == 0) { + return Ok(cumulative_funding); + } + let magnitude = (funding_rate_per_slot as i128) + .checked_mul(elapsed as i128) + .ok_or_else(overflow)?; + let delta = if long_size >= short_size { + magnitude + } else { + -magnitude + }; + cumulative_funding.checked_add(delta).ok_or_else(overflow) +} + +pub fn scale_size(size: u64, entry_price: u64) -> Result { + (size as u128) + .checked_mul(SIZE_PRECISION) + .ok_or_else(overflow)? + .checked_div(entry_price as u128) + .ok_or_else(overflow) +} + +pub fn position_pnl( + side: u8, + size: u64, + entry_price: u64, + price: u64, +) -> Result { + let size = size as i128; + let entry = entry_price as i128; + let price = price as i128; + let price_change = if side == SIDE_LONG { + price.checked_sub(entry) + } else { + entry.checked_sub(price) + } + .ok_or_else(overflow)?; + size.checked_mul(price_change) + .ok_or_else(overflow)? + .checked_div(entry) + .ok_or_else(overflow) +} + +pub fn traders_unrealized_pnl( + long_size: u128, + long_size_scaled: u128, + short_size: u128, + short_size_scaled: u128, + price: u64, +) -> Result { + let price = price as i128; + let size_precision = SIZE_PRECISION as i128; + + let long_value = price + .checked_mul(long_size_scaled as i128) + .ok_or_else(overflow)? + .checked_div(size_precision) + .ok_or_else(overflow)?; + let long_pnl = long_value + .checked_sub(long_size as i128) + .ok_or_else(overflow)?; + + let short_value = price + .checked_mul(short_size_scaled as i128) + .ok_or_else(overflow)? + .checked_div(size_precision) + .ok_or_else(overflow)?; + let short_pnl = (short_size as i128) + .checked_sub(short_value) + .ok_or_else(overflow)?; + + long_pnl.checked_add(short_pnl).ok_or_else(overflow) +} + +pub fn position_funding( + side: u8, + size: u64, + entry_funding: i128, + pool_funding: i128, +) -> Result { + let funding_change = pool_funding + .checked_sub(entry_funding) + .ok_or_else(overflow)?; + let long_owed = (size as i128) + .checked_mul(funding_change) + .ok_or_else(overflow)? + .checked_div(FUNDING_PRECISION) + .ok_or_else(overflow)?; + Ok(if side == SIDE_LONG { + long_owed + } else { + -long_owed + }) +} + +pub fn basis_point_fee(notional: u64, basis_points: u16) -> Result { + let fee = (notional as u128) + .checked_mul(basis_points as u128) + .ok_or_else(overflow)? + .checked_div(BASIS_POINTS_DENOMINATOR as u128) + .ok_or_else(overflow)?; + u64::try_from(fee).map_err(|_| overflow()) +} diff --git a/finance/perpetual-futures/quasar/src/lib.rs b/finance/perpetual-futures/quasar/src/lib.rs new file mode 100644 index 00000000..995baa0b --- /dev/null +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -0,0 +1,129 @@ +#![cfg_attr(not(test), no_std)] + +//! Quasar port of the perpetual-futures example. The design, math, and +//! behaviour match the Anchor sibling at `finance/perpetual-futures/anchor`; see +//! its README for the full walkthrough. This file wires up the program; the +//! per-instruction logic lives in `instructions/`. + +use quasar_lang::prelude::*; + +mod constants; +mod instructions; +pub mod state; +#[cfg(test)] +mod tests; + +use instructions::*; + +declare_id!("GaxH8967GVLxtst2SHCtXxqKQqGxgHyxqYvr9WGe1fmC"); + +/// Authority PDA at seeds = [b"authority", pool]. Signs vault and mint CPIs. +#[derive(Seeds)] +#[seeds(b"authority", pool: Address)] +pub struct PoolAuthorityPda; + +/// Liquidity-provider mint PDA at seeds = [b"lp_mint", pool]. +#[derive(Seeds)] +#[seeds(b"lp_mint", pool: Address)] +pub struct LpMintPda; + +/// Collateral custody vault PDA at seeds = [b"vault", pool]. +#[derive(Seeds)] +#[seeds(b"vault", pool: Address)] +pub struct VaultPda; + +#[program] +mod quasar_perpetual_futures { + use super::*; + + #[instruction(discriminator = 0)] + #[allow(clippy::too_many_arguments)] + pub fn initialize_pool( + ctx: Ctx, + oracle_scale: u32, + funding_rate_per_slot: u64, + open_fee_bps: u16, + close_fee_bps: u16, + max_leverage: u16, + maintenance_margin_bps: u16, + liquidation_fee_bps: u16, + max_confidence_bps: u16, + ) -> Result<(), ProgramError> { + instructions::handle_initialize_pool( + &mut ctx.accounts, + oracle_scale, + funding_rate_per_slot, + open_fee_bps, + close_fee_bps, + max_leverage, + maintenance_margin_bps, + liquidation_fee_bps, + max_confidence_bps, + &ctx.bumps, + ) + } + + #[instruction(discriminator = 1)] + pub fn add_liquidity( + ctx: Ctx, + amount: u64, + minimum_shares_out: u64, + ) -> Result<(), ProgramError> { + instructions::handle_add_liquidity( + &mut ctx.accounts, + amount, + minimum_shares_out, + &ctx.bumps, + ) + } + + #[instruction(discriminator = 2)] + pub fn remove_liquidity( + ctx: Ctx, + shares: u64, + minimum_amount_out: u64, + ) -> Result<(), ProgramError> { + instructions::handle_remove_liquidity( + &mut ctx.accounts, + shares, + minimum_amount_out, + &ctx.bumps, + ) + } + + #[instruction(discriminator = 3)] + pub fn open_position( + ctx: Ctx, + side: u8, + collateral_amount: u64, + size: u64, + acceptable_price: u64, + ) -> Result<(), ProgramError> { + instructions::handle_open_position( + &mut ctx.accounts, + side, + collateral_amount, + size, + acceptable_price, + &ctx.bumps, + ) + } + + #[instruction(discriminator = 4)] + pub fn close_position( + ctx: Ctx, + minimum_payout: u64, + ) -> Result<(), ProgramError> { + instructions::handle_close_position(&mut ctx.accounts, minimum_payout, &ctx.bumps) + } + + #[instruction(discriminator = 5)] + pub fn liquidate_position(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_liquidate_position(&mut ctx.accounts, &ctx.bumps) + } + + #[instruction(discriminator = 6)] + pub fn collect_fees(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_collect_fees(&mut ctx.accounts, &ctx.bumps) + } +} diff --git a/finance/perpetual-futures/quasar/src/state.rs b/finance/perpetual-futures/quasar/src/state.rs new file mode 100644 index 00000000..fd08daa8 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/state.rs @@ -0,0 +1,59 @@ +use quasar_lang::prelude::*; + +/// One perpetual-futures market. Mirrors the Anchor `Pool` field-for-field; see +/// the Anchor sibling's README for what each field means. Money fields are raw +/// base units of the collateral token. +#[account(discriminator = 100, set_inner)] +#[seeds(b"pool", collateral_mint: Address, oracle_feed: Address)] +pub struct Pool { + pub authority: Address, + pub collateral_mint: Address, + pub oracle_feed: Address, + pub custody_vault: Address, + pub lp_mint: Address, + pub oracle_scale: u32, + pub liquidity: u64, + /// Portion of `liquidity` reserved to cover open positions' maximum + /// recoverable profit (one notional `size` each). Withdrawals can only take + /// the free remainder, and a position can open only while + /// `reserved + size <= liquidity`. + pub reserved_liquidity: u64, + pub total_collateral: u64, + pub protocol_fees: u64, + pub long_size: u128, + pub short_size: u128, + pub long_size_scaled: u128, + pub short_size_scaled: u128, + pub cumulative_funding: i128, + pub last_funding_slot: u64, + pub funding_rate_per_slot: u64, + pub open_fee_bps: u16, + pub close_fee_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, + pub bump: u8, + pub authority_bump: u8, +} + +/// One trader's leveraged position, one PDA per (pool, owner). Unlike the Anchor +/// sibling — which seeds the position by side so a trader can hold a long and a +/// short at once — Quasar's `address` constraint can only reference account +/// inputs, not instruction arguments, so `side` is stored in the account rather +/// than used as a seed. A trader therefore holds one position per pool here. +#[account(discriminator = 101, set_inner)] +#[seeds(b"position", pool: Address, owner: Address)] +pub struct Position { + pub owner: Address, + pub pool: Address, + pub side: u8, + pub collateral: u64, + pub size: u64, + pub entry_price: u64, + pub size_scaled: u128, + pub entry_funding: i128, + pub bump: u8, +} diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs new file mode 100644 index 00000000..67099864 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -0,0 +1,608 @@ +extern crate std; + +use { + alloc::{vec, vec::Vec}, + quasar_svm::{ + token::{ + create_keyed_associated_token_account, create_keyed_mint_account, + create_keyed_system_account, Mint, + }, + Account, AccountMeta, Instruction, Pubkey, QuasarSvm, + }, + std::fs, +}; + +const ONE_USDC: u64 = 1_000_000; +const ORACLE_SCALE: u32 = 8; + +fn program_id() -> Pubkey { + crate::ID.into() +} +fn token_program() -> Pubkey { + quasar_svm::SPL_TOKEN_PROGRAM_ID +} +fn ata_program() -> Pubkey { + quasar_svm::SPL_ASSOCIATED_TOKEN_PROGRAM_ID +} +fn system_program() -> Pubkey { + quasar_svm::system_program::ID +} +fn clock_sysvar() -> Pubkey { + "SysvarC1ock11111111111111111111111111111111" + .parse() + .unwrap() +} +fn rent_sysvar() -> Pubkey { + "SysvarRent111111111111111111111111111111111" + .parse() + .unwrap() +} + +fn dollars(whole: i128) -> i128 { + whole * 10i128.pow(ORACLE_SCALE) +} + +fn pda(seeds: &[&[u8]]) -> Pubkey { + Pubkey::find_program_address(seeds, &program_id()).0 +} +fn ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[wallet.as_ref(), token_program().as_ref(), mint.as_ref()], + &ata_program(), + ) + .0 +} + +fn empty(address: &Pubkey) -> Account { + Account { + address: *address, + lamports: 0, + data: vec![], + owner: system_program(), + executable: false, + } +} + +fn mint_account(address: &Pubkey) -> Account { + create_keyed_mint_account( + address, + &Mint { + decimals: 6, + is_initialized: true, + ..Default::default() + }, + ) +} + +/// A feed account in this program's layout: price (i128), scale (u32), +/// last_update_slot (u64), confidence (u64). The tests own this; production +/// reads a real feed. +fn feed_account(address: &Pubkey, price: i128, scale: u32, slot: u64, confidence: u64) -> Account { + let mut data = Vec::with_capacity(36); + data.extend_from_slice(&price.to_le_bytes()); + data.extend_from_slice(&scale.to_le_bytes()); + data.extend_from_slice(&slot.to_le_bytes()); + data.extend_from_slice(&confidence.to_le_bytes()); + Account { + address: *address, + lamports: 1_000_000, + data, + owner: system_program(), + executable: false, + } +} + +fn token_amount(svm: &QuasarSvm, address: &Pubkey) -> u64 { + let account = svm.get_account(address).expect("token account exists"); + // SPL token account layout: mint (32) + owner (32) + amount (u64) at offset 64. + u64::from_le_bytes(account.data[64..72].try_into().unwrap()) +} + +struct Env { + svm: QuasarSvm, + collateral_mint: Pubkey, + feed: Pubkey, + admin: Pubkey, + pool: Pubkey, + pool_authority: Pubkey, + lp_mint: Pubkey, + custody_vault: Pubkey, +} + +const SLOT: u64 = 10; + +/// Build an SVM with the program, token program, a collateral mint, an oracle +/// feed at $100, and an initialized pool. +fn setup() -> Env { + let elf = fs::read("target/deploy/quasar_perpetual_futures.so").unwrap(); + let collateral_mint = Pubkey::new_unique(); + let feed = Pubkey::new_unique(); + let admin = Pubkey::new_unique(); + let pool = pda(&[b"pool", collateral_mint.as_ref(), feed.as_ref()]); + let pool_authority = pda(&[b"authority", pool.as_ref()]); + let lp_mint = pda(&[b"lp_mint", pool.as_ref()]); + let custody_vault = pda(&[b"vault", pool.as_ref()]); + + let mut svm = QuasarSvm::new() + .with_program(&crate::ID, &elf) + .with_token_program() + .with_slot(SLOT) + .with_account(mint_account(&collateral_mint)) + .with_account(feed_account(&feed, dollars(100), ORACLE_SCALE, SLOT, 0)) + .with_account(create_keyed_system_account(&admin, 100_000_000_000)); + + // initialize_pool + let mut data = vec![0u8]; + data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); // funding_rate_per_slot = 0 + data.extend_from_slice(&10u16.to_le_bytes()); // open_fee_bps + data.extend_from_slice(&10u16.to_le_bytes()); // close_fee_bps + data.extend_from_slice(&10u16.to_le_bytes()); // max_leverage + data.extend_from_slice(&500u16.to_le_bytes()); // maintenance_margin_bps + data.extend_from_slice(&100u16.to_le_bytes()); // liquidation_fee_bps + data.extend_from_slice(&100u16.to_le_bytes()); // max_confidence_bps + let metas = vec![ + AccountMeta::new(admin, true), + AccountMeta::new(pool, false), + AccountMeta::new_readonly(collateral_mint, false), + AccountMeta::new_readonly(feed, false), + AccountMeta::new_readonly(pool_authority, false), + AccountMeta::new(lp_mint, false), + AccountMeta::new(custody_vault, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(system_program(), false), + AccountMeta::new_readonly(clock_sysvar(), false), + AccountMeta::new_readonly(rent_sysvar(), false), + ]; + let provided = vec![ + svm.get_account(&admin).unwrap(), + empty(&pool), + svm.get_account(&collateral_mint).unwrap(), + empty(&lp_mint), + empty(&custody_vault), + ]; + let result = svm.process_instruction( + &Instruction { + program_id: program_id(), + accounts: metas, + data, + }, + &provided, + ); + assert!( + result.is_ok(), + "initialize_pool failed: {:?}", + result.raw_result + ); + + Env { + svm, + collateral_mint, + feed, + admin, + pool, + pool_authority, + lp_mint, + custody_vault, + } +} + +impl Env { + /// Create a wallet with a funded collateral token account, returning the + /// wallet and its collateral account. + fn funded_wallet(&mut self, collateral: u64) -> (Pubkey, Pubkey) { + let wallet = Pubkey::new_unique(); + let collateral_account = ata(&wallet, &self.collateral_mint); + self.svm + .set_account(create_keyed_system_account(&wallet, 100_000_000_000)); + self.svm.set_account(create_keyed_associated_token_account( + &wallet, + &self.collateral_mint, + collateral, + )); + (wallet, collateral_account) + } + + fn lp_account(&mut self, wallet: &Pubkey) -> Pubkey { + let account = ata(wallet, &self.lp_mint); + self.svm.set_account(create_keyed_associated_token_account( + wallet, + &self.lp_mint, + 0, + )); + account + } + + fn add_liquidity(&mut self, provider: &Pubkey, amount: u64) -> bool { + let provider_collateral = ata(provider, &self.collateral_mint); + let provider_lp = self.lp_account(provider); + let mut data = vec![1u8]; + data.extend_from_slice(&amount.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + let metas = vec![ + AccountMeta::new(*provider, true), + AccountMeta::new(self.pool, false), + AccountMeta::new_readonly(self.pool_authority, false), + AccountMeta::new_readonly(self.feed, false), + AccountMeta::new_readonly(self.collateral_mint, false), + AccountMeta::new(self.lp_mint, false), + AccountMeta::new(self.custody_vault, false), + AccountMeta::new(provider_collateral, false), + AccountMeta::new(provider_lp, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(system_program(), false), + AccountMeta::new_readonly(clock_sysvar(), false), + ]; + self.run(metas, data, &[*provider, provider_collateral, provider_lp]) + } + + fn remove_liquidity(&mut self, provider: &Pubkey, shares: u64) -> bool { + let provider_collateral = ata(provider, &self.collateral_mint); + let provider_lp = ata(provider, &self.lp_mint); + let mut data = vec![2u8]; + data.extend_from_slice(&shares.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + let metas = vec![ + AccountMeta::new(*provider, true), + AccountMeta::new(self.pool, false), + AccountMeta::new_readonly(self.pool_authority, false), + AccountMeta::new_readonly(self.feed, false), + AccountMeta::new_readonly(self.collateral_mint, false), + AccountMeta::new(self.lp_mint, false), + AccountMeta::new(self.custody_vault, false), + AccountMeta::new(provider_collateral, false), + AccountMeta::new(provider_lp, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(system_program(), false), + AccountMeta::new_readonly(clock_sysvar(), false), + ]; + self.run(metas, data, &[*provider, provider_collateral, provider_lp]) + } + + fn open_position(&mut self, owner: &Pubkey, side: u8, collateral: u64, size: u64) -> bool { + let trader_collateral = ata(owner, &self.collateral_mint); + let position = pda(&[b"position", self.pool.as_ref(), owner.as_ref()]); + let mut data = vec![3u8, side]; + data.extend_from_slice(&collateral.to_le_bytes()); + data.extend_from_slice(&size.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + let metas = vec![ + AccountMeta::new(*owner, true), + AccountMeta::new(self.pool, false), + AccountMeta::new(position, false), + AccountMeta::new_readonly(self.feed, false), + AccountMeta::new_readonly(self.collateral_mint, false), + AccountMeta::new(self.custody_vault, false), + AccountMeta::new(trader_collateral, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(system_program(), false), + AccountMeta::new_readonly(clock_sysvar(), false), + AccountMeta::new_readonly(rent_sysvar(), false), + ]; + self.run(metas, data, &[*owner, position, trader_collateral]) + } + + fn set_price(&mut self, price: i128) { + self.svm + .set_account(feed_account(&self.feed, price, ORACLE_SCALE, SLOT, 0)); + } + + fn set_price_with_confidence(&mut self, price: i128, confidence: u64) { + self.svm.set_account(feed_account( + &self.feed, + price, + ORACLE_SCALE, + SLOT, + confidence, + )); + } + + fn close_position(&mut self, owner: &Pubkey) -> bool { + let trader_collateral = ata(owner, &self.collateral_mint); + let position = pda(&[b"position", self.pool.as_ref(), owner.as_ref()]); + let mut data = vec![4u8]; + data.extend_from_slice(&0u64.to_le_bytes()); + let metas = vec![ + AccountMeta::new(*owner, true), + AccountMeta::new(self.pool, false), + AccountMeta::new(position, false), + AccountMeta::new_readonly(self.pool_authority, false), + AccountMeta::new_readonly(self.feed, false), + AccountMeta::new_readonly(self.collateral_mint, false), + AccountMeta::new(self.custody_vault, false), + AccountMeta::new(trader_collateral, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(system_program(), false), + AccountMeta::new_readonly(clock_sysvar(), false), + ]; + self.run(metas, data, &[*owner, position, trader_collateral]) + } + + fn liquidate(&mut self, liquidator: &Pubkey, owner: &Pubkey) -> bool { + let trader_collateral = ata(owner, &self.collateral_mint); + let liquidator_collateral = ata(liquidator, &self.collateral_mint); + self.svm.set_account(create_keyed_associated_token_account( + liquidator, + &self.collateral_mint, + 0, + )); + let position = pda(&[b"position", self.pool.as_ref(), owner.as_ref()]); + let data = vec![5u8]; + let metas = vec![ + AccountMeta::new(*liquidator, true), + AccountMeta::new(*owner, false), + AccountMeta::new(self.pool, false), + AccountMeta::new(position, false), + AccountMeta::new_readonly(self.pool_authority, false), + AccountMeta::new_readonly(self.feed, false), + AccountMeta::new_readonly(self.collateral_mint, false), + AccountMeta::new(self.custody_vault, false), + AccountMeta::new(trader_collateral, false), + AccountMeta::new(liquidator_collateral, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(system_program(), false), + AccountMeta::new_readonly(clock_sysvar(), false), + ]; + self.run( + metas, + data, + &[ + *liquidator, + *owner, + position, + trader_collateral, + liquidator_collateral, + ], + ) + } + + fn collect_fees(&mut self) -> bool { + let admin = self.admin; + let admin_collateral = ata(&admin, &self.collateral_mint); + self.svm.set_account(create_keyed_associated_token_account( + &admin, + &self.collateral_mint, + 0, + )); + let data = vec![6u8]; + let metas = vec![ + AccountMeta::new(admin, true), + AccountMeta::new(self.pool, false), + AccountMeta::new_readonly(self.pool_authority, false), + AccountMeta::new_readonly(self.feed, false), + AccountMeta::new_readonly(self.collateral_mint, false), + AccountMeta::new(self.custody_vault, false), + AccountMeta::new(admin_collateral, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(system_program(), false), + ]; + self.run(metas, data, &[admin, admin_collateral]) + } + + fn run(&mut self, metas: Vec, data: Vec, provide: &[Pubkey]) -> bool { + let accounts: Vec = provide + .iter() + .map(|pk| self.svm.get_account(pk).unwrap_or_else(|| empty(pk))) + .collect(); + let result = self.svm.process_instruction( + &Instruction { + program_id: program_id(), + accounts: metas, + data, + }, + &accounts, + ); + result.is_ok() + } +} + +#[test] +fn test_initialize_pool() { + let env = setup(); + // The pool, vault, and liquidity-provider mint were created. + assert!(env.svm.get_account(&env.pool).is_some()); + assert!(env.svm.get_account(&env.custody_vault).is_some()); + assert!(env.svm.get_account(&env.lp_mint).is_some()); +} + +#[test] +fn test_add_liquidity() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(10_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 10_000 * ONE_USDC)); + + // The vault holds the deposit and the provider received shares. + assert_eq!( + token_amount(&env.svm, &env.custody_vault), + 10_000 * ONE_USDC + ); + let provider_lp = ata(&provider, &env.lp_mint); + assert_eq!( + token_amount(&env.svm, &provider_lp), + 10_000 * ONE_USDC - 1_000 + ); +} + +#[test] +fn test_remove_liquidity_round_trip() { + let mut env = setup(); + let (provider, provider_collateral) = env.funded_wallet(10_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 10_000 * ONE_USDC)); + + let provider_lp = ata(&provider, &env.lp_mint); + let shares = token_amount(&env.svm, &provider_lp); + let mut data = vec![2u8]; + data.extend_from_slice(&shares.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + let metas = vec![ + AccountMeta::new(provider, true), + AccountMeta::new(env.pool, false), + AccountMeta::new_readonly(env.pool_authority, false), + AccountMeta::new_readonly(env.feed, false), + AccountMeta::new_readonly(env.collateral_mint, false), + AccountMeta::new(env.lp_mint, false), + AccountMeta::new(env.custody_vault, false), + AccountMeta::new(provider_collateral, false), + AccountMeta::new(provider_lp, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(system_program(), false), + AccountMeta::new_readonly(clock_sysvar(), false), + ]; + assert!(env.run(metas, data, &[provider, provider_collateral, provider_lp])); + + // Sole provider reclaims the full deposit. + assert_eq!( + token_amount(&env.svm, &provider_collateral), + 10_000 * ONE_USDC + ); +} + +#[test] +fn test_open_long_position() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); + assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); + + let position = pda(&[b"position", env.pool.as_ref(), trader.as_ref()]); + assert!(env.svm.get_account(&position).is_some()); +} + +#[test] +fn test_close_long_in_profit() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let (trader, trader_collateral) = env.funded_wallet(1_000 * ONE_USDC); + let size = 5_000 * ONE_USDC; + assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, size)); + + // Price rises 20%: a $5,000 long earns $1,000. + env.set_price(dollars(120)); + assert!(env.close_position(&trader)); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = 1_000 * ONE_USDC - open_fee; + let profit = size / 5; + let expected = net_collateral + profit - close_fee; + assert_eq!(token_amount(&env.svm, &trader_collateral), expected); +} + +#[test] +fn test_open_rejects_excess_leverage() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); + // 11x exceeds the 10x maximum. + assert!(!env.open_position(&trader, 0, 1_000 * ONE_USDC, 11_000 * ONE_USDC)); +} + +#[test] +fn test_liquidate_underwater_long() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let (trader, _) = env.funded_wallet(1_100 * ONE_USDC); + let size = 10_000 * ONE_USDC; + assert!(env.open_position(&trader, 0, 1_100 * ONE_USDC, size)); + + // Price falls 9%: a $10,000 long loses $900, dropping below maintenance. + env.set_price(dollars(91)); + let liquidator = Pubkey::new_unique(); + env.svm + .set_account(create_keyed_system_account(&liquidator, 100_000_000_000)); + assert!(env.liquidate(&liquidator, &trader)); + + let liquidator_collateral = ata(&liquidator, &env.collateral_mint); + assert!(token_amount(&env.svm, &liquidator_collateral) > 0); + let position = pda(&[b"position", env.pool.as_ref(), trader.as_ref()]); + assert!(env + .svm + .get_account(&position) + .map(|a| a.data.is_empty()) + .unwrap_or(true)); +} + +#[test] +fn test_collect_fees() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); + let size = 5_000 * ONE_USDC; + assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, size)); + + assert!(env.collect_fees()); + let admin_collateral = ata(&env.admin, &env.collateral_mint); + // The open fee (0.1% of notional) was swept to the admin. + assert_eq!(token_amount(&env.svm, &admin_collateral), size / 1_000); +} + +#[test] +fn test_wide_oracle_confidence_rejected() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); + + // The pool tolerates a 1% confidence band (max_confidence_bps = 100). Widen + // the feed's band to 2% of the price and the open must be rejected. + env.set_price_with_confidence(dollars(100), dollars(2) as u64); + assert!(!env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); +} + +#[test] +fn test_open_rejects_when_pool_cannot_back_it() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(3_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 3_000 * ONE_USDC)); + let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); + // A 5,000 position must reserve 5,000, but the pool only holds 3,000. + assert!(!env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); +} + +#[test] +fn test_profit_capped_at_reserved_notional() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let collateral = 2_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = env.funded_wallet(collateral); + assert!(env.open_position(&trader, 0, collateral, size)); + + // Price triples: uncapped profit would be 2x the notional, but recoverable + // profit is capped at the reserved notional (`size`). + env.set_price(dollars(300)); + assert!(env.close_position(&trader)); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let expected = net_collateral + size - close_fee; + assert_eq!(token_amount(&env.svm, &trader_collateral), expected); +} + +#[test] +fn test_remove_liquidity_blocked_by_reserved() { + let mut env = setup(); + let (provider, _) = env.funded_wallet(10_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 10_000 * ONE_USDC)); + let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); + assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); + + // 5,000 of the 10,000 liquidity is reserved: pulling everything fails, but + // withdrawing within the free half succeeds. + let provider_lp = ata(&provider, &env.lp_mint); + let shares = token_amount(&env.svm, &provider_lp); + assert!(!env.remove_liquidity(&provider, shares)); + assert!(env.remove_liquidity(&provider, shares / 2)); +}