diff --git a/.github/workflows/kani.yml b/.github/workflows/kani.yml index 2ba7a5aa..ae3e5b27 100644 --- a/.github/workflows/kani.yml +++ b/.github/workflows/kani.yml @@ -60,6 +60,7 @@ jobs: - betting-market - vault-strategy - token-fundraiser + - prop-amm steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable @@ -86,6 +87,7 @@ jobs: - betting-market - vault-strategy - token-fundraiser + - prop-amm steps: - uses: actions/checkout@v5 - name: Run Kani diff --git a/README.md b/README.md index 62831428..0b08be1e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ An exchange with no order book: swaps fill instantly against a shared liquidity [⚓ Anchor](./finance/token-swap/anchor) [💫 Quasar](./finance/token-swap/quasar) +### Prop AMM + +A **proprietary AMM**: a market-making firm funds a venue with its own capital and quotes both sides of it, selling the base token at the oracle price plus a spread and buying it back at the oracle price minus the spread. No pricing curve, no liquidity providers, no pool shares — the operator is the only capital in the market, can re-quote or pull its quotes at will, and earns the spread instead of a fee. Because the price comes from an oracle rather than the pool's balances, trades have no price impact and nothing to sandwich. This is the design behind venues like Lifinity, SolFi, and HumidiFi, which fill most Solana swap volume through Jupiter routing. + +[⚓ Anchor](./finance/prop-amm/anchor) [💫 Quasar](./finance/prop-amm/quasar) + ### Vault Strategy A managed investment fund onchain, like an ETF or mutual fund. Investors deposit USDC for shares, a manager allocates the pool across a basket of assets (here, stocks like TSLAx and NVDAx), and each share's value tracks the fund's net asset value. The manager earns a management fee, and investors redeem a proportional slice of the underlying assets. diff --git a/finance/prop-amm/anchor/.gitignore b/finance/prop-amm/anchor/.gitignore new file mode 100644 index 00000000..be06d3aa --- /dev/null +++ b/finance/prop-amm/anchor/.gitignore @@ -0,0 +1,6 @@ +.anchor +target +**/*.rs.bk +node_modules +test-ledger +.DS_Store diff --git a/finance/prop-amm/anchor/Anchor.toml b/finance/prop-amm/anchor/Anchor.toml new file mode 100644 index 00000000..4284d903 --- /dev/null +++ b/finance/prop-amm/anchor/Anchor.toml @@ -0,0 +1,26 @@ +[toolchain] +# Match the repo package manager (pnpm-lock.yaml at root); avoids Anchor's yarn default. +package_manager = "pnpm" +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +prop_amm = "9ZMtJFtn5n4wwpEeXXG5paFQakcDtrd3ova5ptJL4VT1" +mock_switchboard = "BdFYarCfgMJhC26dFN6JXKVx4kUEM3k2wYaNjyjFrvAD" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "cargo test" + +# Non-default: the LiteSVM Rust tests load both programs' .so files, 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/prop-amm/anchor/CHANGELOG.md b/finance/prop-amm/anchor/CHANGELOG.md new file mode 100644 index 00000000..1d867739 --- /dev/null +++ b/finance/prop-amm/anchor/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 2026-07-11 + +Initial version: an oracle-quoted proprietary AMM. One operator funds the +market's inventory and quotes both sides of it at the oracle price plus a +spread; anyone can swap against the quotes. Includes the `mock-switchboard` +oracle program for deterministic tests. diff --git a/finance/prop-amm/anchor/Cargo.toml b/finance/prop-amm/anchor/Cargo.toml new file mode 100644 index 00000000..f3977048 --- /dev/null +++ b/finance/prop-amm/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/prop-amm/anchor/README.md b/finance/prop-amm/anchor/README.md new file mode 100644 index 00000000..66495e5b --- /dev/null +++ b/finance/prop-amm/anchor/README.md @@ -0,0 +1,156 @@ +# Prop AMM + +An oracle-quoted **proprietary AMM**: a market-making firm funds a trading +venue with its own capital and quotes both sides of it — anyone can buy the +base token at the oracle price plus a spread, or sell at the oracle price +minus it. There is no pricing curve, there are no liquidity providers, and +there are no pool shares: the operator is the only capital in the market, +which is the property that gives the design its name. This is the +architecture behind venues like Lifinity (the pioneer), SolFi, HumidiFi, +ZeroFi, Tessera, and Obric, which collectively fill most Solana swap volume +via Jupiter routing rather than their own user interfaces. + +## Programs + +- **`prop-amm`** — the market: one operator, one base/quote pair, one oracle + feed, two vaults, five instruction handlers. +- **`mock-switchboard`** — a minimal stand-in for a Switchboard On-Demand + price feed, so tests can drive deterministic price scenarios. Not for + production. + +## Key Financial Concepts + +### Proprietary market making + +A constant-product AMM crowdsources its capital from liquidity providers and +pays them fees; its price is a function of its reserves. A prop AMM inverts +all of it: the firm quotes prices taken from an oracle, earns the spread +instead of a fee, and risks only its own inventory. Because the price does +not depend on the pool's balances, big trades pay the same unit price as +small ones (no price impact), and there is nothing for a sandwich attacker to +squeeze — the classic front-run/back-run pattern needs a price that moves +with each trade. + +### The quote: oracle, spread, bid and ask + +The market pins an oracle feed at creation. Every swap reads the current +price and applies the operator's `spread_bps` each way: buyers of the base +token pay the **ask** (oracle plus spread, rounded up), sellers receive the +**bid** (oracle minus spread, rounded down). Output amounts floor. Every +rounding direction favors the market, and after the math the handler asserts +the invariant those roundings guarantee: the value leaving the vaults, at the +raw oracle price, never exceeds the value coming in. + +### Inventory, not liquidity + +The vault balances are not a pricing input; they only bound what the market +can deliver. A swap bigger than the inventory is rejected whole +(`InsufficientInventory`) rather than partially filled or mispriced. The +operator deposits and withdraws inventory freely — including all of it, at any +time. Nobody else has a claim on the vaults, so there is no share mint, no +pro-rata withdrawal math, and no inflation attack surface: the empty-pool +games that plague shared pools need shares to dilute, and there are none. + +### Adverse selection and pulled quotes + +A market maker's enemy is informed flow: traders who know the price is about +to move and hit the stale side of the quote. The two defenses this program +ships are the oracle gates (below) and `set_quote`, which lets the operator +widen the spread or pause quoting entirely. Real prop AMMs do exactly this — +during fast markets their quotes vanish and return minutes later. + +### Oracle staleness and confidence + +Every swap re-validates the feed: the price must be positive, at the pinned +scale, no older than 150 slots (~1 minute), and its confidence band must be +inside `max_confidence_bps`. For this design the staleness bound is not +hygiene, it is the business: a quote priced off an old number is a free +option for whoever notices first. + +## Program Flow + +### Participants + +- **Maria** operates the market-making firm. +- **Alice** and **Bob** trade NVDAx (tokenized NVIDIA stock, 6 decimals) + against USDC. +- The oracle quotes NVDAx at **$165** with 8 decimals of scale. + +### Step 1: Maria opens the market + +`initialize_market` creates the `Market` account (PDA of the mint pair), a +dataless vault-authority PDA, and the two vaults, and pins the oracle feed, +its scale, a 10 bps spread, and a 1% confidence limit. One market per pair: +the deployment is the firm. + +### Step 2: Maria stocks the inventory + +`deposit_inventory` moves 1,000 NVDAx and 200,000 USDC of the firm's own +tokens into the vaults. No shares are minted to anyone, because there is +nobody else to account for. + +### Step 3: Alice buys 10 NVDAx at the ask + +At $165 with a 10 bps spread the ask is $165.165. Alice's `swap` +(`Direction::BuyBase`) spends exactly 1,651.65 USDC for 10 NVDAx — +whether she bought 1 or 500, the unit price would be the same. + +### Step 4: Bob sells 10 NVDAx at the bid + +The bid is $164.835, so Bob's `swap` (`Direction::SellBase`) receives +exactly 1,648.35 USDC. A round trip through both sides costs exactly the +3.30 USDC spread — the spread is the fee, and it lands in the inventory, +not in a fee ledger. + +### Step 5: The oracle reprices; the quote follows + +The feed moves to $170 and the very next swap prices at $170.17 ask. No +arbitrageur had to walk the price there trade by trade, which is how a curve +AMM gets from one price to another. + +### Step 6: Volatility: Maria widens the spread, then pulls quotes + +`set_quote(50, false)` re-prices the market at a 50 bps spread; +`set_quote(50, true)` pauses it entirely, and swaps fail with +`MarketPaused` until she returns. + +### Step 7: Maria withdraws her inventory + +`withdraw_inventory` returns every token in both vaults to the firm. The +market still exists but rejects fills — an empty prop AMM refuses rather than +misprices. + +## Design notes and further reading + +- Production prop AMMs on Solana are closed-source and considerably more + sophisticated: they blend multiple price sources, run inventory-skewed + quoting (shading the quote to reduce a lopsided inventory), and integrate + with aggregators via quote APIs. The skeleton — operator capital, oracle + price, spread, hard oracle gates — is this program. +- Lifinity's public design notes and the Helius write-up + "Solana's Proprietary AMM Revolution" are good next reads. +- The oracle reader deliberately reads raw bytes at fixed offsets and + documents how to swap in `switchboard_on_demand::PullFeedAccountData:: + parse_and_verify(...)` for production. + +## Limitations + +- The oracle feed's owning program is not verified — the operator picks the + feed, and a bad choice loses the operator's money, not the traders'. A + production reader must still check the account owner. +- One flat spread both ways; no inventory skew, no size-dependent pricing. +- `paused` is the only circuit breaker; production venues also bound + per-slot volume and single-fill size. + +## Testing + +```bash +anchor build +cargo test +``` + +The LiteSVM suite (`programs/prop-amm/tests/test_prop_amm.rs`) verifies the +quote math to the minor unit in both directions, the exact round-trip spread, +oracle repricing and re-quoting, and that every gate shuts: slippage, +staleness, confidence, pause, zero amounts, inventory bounds, and operator +access control. diff --git a/finance/prop-amm/anchor/programs/mock-switchboard/Cargo.toml b/finance/prop-amm/anchor/programs/mock-switchboard/Cargo.toml new file mode 100644 index 00000000..5374a001 --- /dev/null +++ b/finance/prop-amm/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 prop-amm 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.1.2" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/finance/prop-amm/anchor/programs/mock-switchboard/src/lib.rs b/finance/prop-amm/anchor/programs/mock-switchboard/src/lib.rs new file mode 100644 index 00000000..66d29a65 --- /dev/null +++ b/finance/prop-amm/anchor/programs/mock-switchboard/src/lib.rs @@ -0,0 +1,105 @@ +//! Mock Switchboard On-Demand feed for testing the prop-amm 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 prop-amm 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 `prop_amm::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!("BdFYarCfgMJhC26dFN6JXKVx4kUEM3k2wYaNjyjFrvAD"); + +#[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 +/// prop-amm program needs to price a quote. +#[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/prop-amm/anchor/programs/prop-amm/Cargo.toml b/finance/prop-amm/anchor/programs/prop-amm/Cargo.toml new file mode 100644 index 00000000..a13aa36c --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "prop_amm" +version = "0.1.0" +description = "Oracle-quoted proprietary AMM example (Lifinity / SolFi / HumidiFi-style)" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "prop_amm" + +[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: swap creates the trader's destination token account when it +# doesn't exist yet, so a first-time buyer needs no separate setup transaction. +anchor-lang = { version = "1.1.2", features = ["init-if-needed"] } +anchor-spl = "1.1.2" +# Declared only so Cargo feature-unification turns on `no-entrypoint`; without +# these the test binary links two `entrypoint` symbols and fails to build. +spl-token = { version = "9.0.0", features = ["no-entrypoint"] } +spl-associated-token-account = { version = "8.0.0", features = ["no-entrypoint"] } + +[dev-dependencies] +litesvm = "0.13.1" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +solana-kite = "0.4.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/prop-amm/anchor/programs/prop-amm/src/constants.rs b/finance/prop-amm/anchor/programs/prop-amm/src/constants.rs new file mode 100644 index 00000000..186587f2 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/constants.rs @@ -0,0 +1,24 @@ +use anchor_lang::prelude::*; + +/// Basis-point denominator: 100% = 10_000 bps. The spread and the oracle +/// confidence limit are expressed in basis points and divided by this. +#[constant] +pub const BASIS_POINTS_DENOMINATOR: u64 = 10_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. For a market maker this bound is not a +/// nicety: a stale quote is a free option for whoever notices first. +pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; + +#[constant] +pub const MARKET_SEED: &[u8] = b"market"; + +#[constant] +pub const AUTHORITY_SEED: &[u8] = b"authority"; + +#[constant] +pub const BASE_VAULT_SEED: &[u8] = b"base_vault"; + +#[constant] +pub const QUOTE_VAULT_SEED: &[u8] = b"quote_vault"; diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs b/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs new file mode 100644 index 00000000..10d9cfca --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs @@ -0,0 +1,43 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum PropAmmError { + #[msg("Market parameter is outside the allowed range")] + InvalidParameter, + + #[msg("Amount must be greater than zero")] + ZeroAmount, + + #[msg("Computed output amount rounded down to zero")] + AmountRoundsToZero, + + #[msg("Arithmetic overflow")] + MathOverflow, + + #[msg("The operator has paused this market's quotes")] + MarketPaused, + + #[msg("Output is below the caller's minimum")] + SlippageExceeded, + + #[msg("The market does not hold enough inventory to fill this swap")] + InsufficientInventory, + + #[msg("Swap output exceeds the input's value at the oracle price")] + InvariantViolated, + + #[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 market configuration")] + OracleScaleMismatch, + + #[msg("Oracle feed account data is too short to decode")] + OracleDataTooShort, + + #[msg("Oracle price confidence band is too wide to trust")] + OracleConfidenceTooWide, +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/deposit_inventory.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/deposit_inventory.rs new file mode 100644 index 00000000..7cd7b681 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/deposit_inventory.rs @@ -0,0 +1,108 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::{BASE_VAULT_SEED, MARKET_SEED, QUOTE_VAULT_SEED}; +use crate::errors::PropAmmError; +use crate::state::Market; + +pub fn handle_deposit_inventory( + context: Context, + base_amount: u64, + quote_amount: u64, +) -> Result<()> { + require!( + base_amount > 0 || quote_amount > 0, + PropAmmError::ZeroAmount + ); + + if base_amount > 0 { + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.operator_base.to_account_info(), + mint: context.accounts.base_mint.to_account_info(), + to: context.accounts.base_vault.to_account_info(), + authority: context.accounts.operator.to_account_info(), + }, + ), + base_amount, + context.accounts.base_mint.decimals, + )?; + } + + if quote_amount > 0 { + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.operator_quote.to_account_info(), + mint: context.accounts.quote_mint.to_account_info(), + to: context.accounts.quote_vault.to_account_info(), + authority: context.accounts.operator.to_account_info(), + }, + ), + quote_amount, + context.accounts.quote_mint.decimals, + )?; + } + + Ok(()) +} + +#[derive(Accounts)] +pub struct DepositInventoryAccountConstraints<'info> { + // `has_one = operator` on the market is the whole access control: only the + // firm's key can stock the market. + #[account(mut)] + pub operator: Signer<'info>, + + #[account( + seeds = [MARKET_SEED, market.base_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + has_one = operator, + has_one = base_mint, + has_one = quote_mint, + has_one = base_vault, + has_one = quote_vault, + )] + pub market: Box>, + + pub base_mint: Box>, + + pub quote_mint: Box>, + + #[account( + mut, + seeds = [BASE_VAULT_SEED, market.key().as_ref()], + bump, + )] + pub base_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + )] + pub quote_vault: Box>, + + #[account( + mut, + associated_token::mint = base_mint, + associated_token::authority = operator, + associated_token::token_program = token_program, + )] + pub operator_base: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = operator, + associated_token::token_program = token_program, + )] + pub operator_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/initialize_market.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/initialize_market.rs new file mode 100644 index 00000000..056fc96a --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/initialize_market.rs @@ -0,0 +1,131 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{ + AUTHORITY_SEED, BASE_VAULT_SEED, BASIS_POINTS_DENOMINATOR, MARKET_SEED, QUOTE_VAULT_SEED, +}; +use crate::errors::PropAmmError; +use crate::state::Market; + +/// Quote parameters set at market creation. Bundled into one struct so the +/// instruction signature stays readable. +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct MarketParameters { + /// Decimal places the oracle quotes its price in (e.g. 8). + pub oracle_scale: u32, + + /// Half-spread in basis points: ask = oracle + spread, bid = oracle - spread. + pub spread_bps: u16, + + /// Maximum oracle confidence band tolerated, in basis points of the price. + pub max_confidence_bps: u16, +} + +pub fn handle_initialize_market( + context: Context, + parameters: MarketParameters, +) -> Result<()> { + let denominator = BASIS_POINTS_DENOMINATOR as u16; + // A market quoting the same token against itself prices nothing. + require_keys_neq!( + context.accounts.base_mint.key(), + context.accounts.quote_mint.key(), + PropAmmError::InvalidParameter + ); + // Zero spread means quoting the oracle price for free while paying adverse + // selection on every fill — almost certainly a configuration mistake, so + // it is rejected rather than allowed to bleed. At or above 100% the bid + // goes to zero or below and the quote stops meaning anything. + require!( + parameters.spread_bps > 0 && parameters.spread_bps < denominator, + PropAmmError::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, + PropAmmError::InvalidParameter + ); + + let market = &mut context.accounts.market; + market.operator = context.accounts.operator.key(); + market.base_mint = context.accounts.base_mint.key(); + market.quote_mint = context.accounts.quote_mint.key(); + market.oracle_feed = context.accounts.oracle_feed.key(); + market.base_vault = context.accounts.base_vault.key(); + market.quote_vault = context.accounts.quote_vault.key(); + market.oracle_scale = parameters.oracle_scale; + market.base_decimals = context.accounts.base_mint.decimals; + market.quote_decimals = context.accounts.quote_mint.decimals; + market.spread_bps = parameters.spread_bps; + market.max_confidence_bps = parameters.max_confidence_bps; + market.paused = false; + market.bump = context.bumps.market; + market.authority_bump = context.bumps.market_authority; + + Ok(()) +} + +#[derive(Accounts)] +pub struct InitializeMarketAccountConstraints<'info> { + #[account(mut)] + pub operator: Signer<'info>, + + // One market per pair: the deployment IS the firm. A real prop AMM is a + // closed program deployed by the market-making firm itself, so there is no + // reason for two markets in the same pair to coexist in one deployment. + #[account( + init, + payer = operator, + space = Market::DISCRIMINATOR.len() + Market::INIT_SPACE, + seeds = [MARKET_SEED, base_mint.key().as_ref(), quote_mint.key().as_ref()], + bump, + )] + pub market: Box>, + + pub base_mint: Box>, + + pub quote_mint: Box>, + + /// CHECK: The oracle feed account. Its key is stored on the market 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 both vaults. Holds no data; used only to sign + /// vault CPIs. + #[account( + seeds = [AUTHORITY_SEED, market.key().as_ref()], + bump, + )] + pub market_authority: UncheckedAccount<'info>, + + #[account( + init, + payer = operator, + seeds = [BASE_VAULT_SEED, market.key().as_ref()], + bump, + token::mint = base_mint, + token::authority = market_authority, + token::token_program = token_program, + )] + pub base_vault: Box>, + + #[account( + init, + payer = operator, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + token::mint = quote_mint, + token::authority = market_authority, + token::token_program = token_program, + )] + pub quote_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/prop-amm/anchor/programs/prop-amm/src/instructions/mod.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/mod.rs new file mode 100644 index 00000000..8d4b5caf --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/mod.rs @@ -0,0 +1,11 @@ +pub mod deposit_inventory; +pub mod initialize_market; +pub mod set_quote; +pub mod swap; +pub mod withdraw_inventory; + +pub use deposit_inventory::*; +pub use initialize_market::*; +pub use set_quote::*; +pub use swap::*; +pub use withdraw_inventory::*; diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/set_quote.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/set_quote.rs new file mode 100644 index 00000000..0b00ce17 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/set_quote.rs @@ -0,0 +1,44 @@ +use anchor_lang::prelude::*; + +use crate::constants::{BASIS_POINTS_DENOMINATOR, MARKET_SEED}; +use crate::errors::PropAmmError; +use crate::state::Market; + +/// Re-quote the market: change the spread, pull the quotes, or restore them. +/// +/// For a market-making firm this is the most-used instruction in the program. +/// A fixed spread is only safe while the world is calm; when volatility rises +/// (or the firm's models disagree with the oracle), the rational moves are to +/// widen the spread or stop quoting entirely. Onchain prop AMMs do exactly +/// this — during fast markets their quotes vanish and return minutes later. +pub fn handle_set_quote( + context: Context, + spread_bps: u16, + paused: bool, +) -> Result<()> { + // Same bounds as at creation: a quote must charge something and the bid + // must stay positive. + require!( + spread_bps > 0 && spread_bps < BASIS_POINTS_DENOMINATOR as u16, + PropAmmError::InvalidParameter + ); + + let market = &mut context.accounts.market; + market.spread_bps = spread_bps; + market.paused = paused; + + Ok(()) +} + +#[derive(Accounts)] +pub struct SetQuoteAccountConstraints<'info> { + pub operator: Signer<'info>, + + #[account( + mut, + seeds = [MARKET_SEED, market.base_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + has_one = operator, + )] + pub market: Box>, +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/swap.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/swap.rs new file mode 100644 index 00000000..ea0f26f9 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/swap.rs @@ -0,0 +1,251 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, +}; + +use crate::constants::{AUTHORITY_SEED, BASE_VAULT_SEED, MARKET_SEED, QUOTE_VAULT_SEED}; +use crate::errors::PropAmmError; +use crate::quote_math; +use crate::state::oracle::read_oracle_price; +use crate::state::{Direction, Market}; + +/// Fill a swap against the operator's quote. +/// +/// The price does not depend on the vault balances, on the size of the trade, +/// or on who traded before you: it is the oracle price plus or minus the +/// spread, full stop. The balances only decide whether the market *can* fill +/// you — a curve AMM's reserves are its pricing input, a prop AMM's inventory +/// is just its ammunition. +pub fn handle_swap( + context: Context, + direction: Direction, + amount_in: u64, + minimum_amount_out: u64, +) -> Result<()> { + let market = &context.accounts.market; + require!(!market.paused, PropAmmError::MarketPaused); + require!(amount_in > 0, PropAmmError::ZeroAmount); + + // Freshness, scale, and confidence are all enforced inside the read. For a + // market maker the staleness bound is the business itself: a quote priced + // off an old number is a free option for whoever notices first. + let oracle_price = read_oracle_price( + &context.accounts.oracle_feed, + market.oracle_scale, + market.max_confidence_bps, + )?; + + let (amount_out, respects_oracle_value) = match direction { + Direction::BuyBase => { + let ask = quote_math::ask_price(oracle_price, market.spread_bps) + .ok_or(PropAmmError::MathOverflow)?; + let base_out = quote_math::base_out_for_quote_in( + amount_in, + ask, + market.oracle_scale, + market.base_decimals, + market.quote_decimals, + ) + .ok_or(PropAmmError::MathOverflow)?; + let respects = quote_math::buy_respects_oracle_value( + amount_in, + base_out, + oracle_price, + market.oracle_scale, + market.base_decimals, + market.quote_decimals, + ) + .ok_or(PropAmmError::MathOverflow)?; + (base_out, respects) + } + Direction::SellBase => { + let bid = quote_math::bid_price(oracle_price, market.spread_bps) + .ok_or(PropAmmError::MathOverflow)?; + let quote_out = quote_math::quote_out_for_base_in( + amount_in, + bid, + market.oracle_scale, + market.base_decimals, + market.quote_decimals, + ) + .ok_or(PropAmmError::MathOverflow)?; + let respects = quote_math::sell_respects_oracle_value( + amount_in, + quote_out, + oracle_price, + market.oracle_scale, + market.base_decimals, + market.quote_decimals, + ) + .ok_or(PropAmmError::MathOverflow)?; + (quote_out, respects) + } + }; + + require!(amount_out > 0, PropAmmError::AmountRoundsToZero); + require!( + amount_out >= minimum_amount_out, + PropAmmError::SlippageExceeded + ); + + // Assert after the math, not just before: whatever the quoting arithmetic + // above produced, the market must never hand out more value than it took + // in, measured at the raw oracle price. The spread and the rounding + // directions make this true by construction; this check makes it true even + // if a refactor breaks one of them. + require!(respects_oracle_value, PropAmmError::InvariantViolated); + + let (vault_out_balance, out_mint_decimals) = match direction { + Direction::BuyBase => ( + context.accounts.base_vault.amount, + context.accounts.base_mint.decimals, + ), + Direction::SellBase => ( + context.accounts.quote_vault.amount, + context.accounts.quote_mint.decimals, + ), + }; + require!( + amount_out <= vault_out_balance, + PropAmmError::InsufficientInventory + ); + + let market_key = market.key(); + let authority_seeds: &[&[u8]] = &[ + AUTHORITY_SEED, + market_key.as_ref(), + &[market.authority_bump], + ]; + + // The trader pays in, then the vault pays out, atomically or not at all. + match direction { + Direction::BuyBase => { + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.trader_quote.to_account_info(), + mint: context.accounts.quote_mint.to_account_info(), + to: context.accounts.quote_vault.to_account_info(), + authority: context.accounts.trader.to_account_info(), + }, + ), + amount_in, + context.accounts.quote_mint.decimals, + )?; + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.base_vault.to_account_info(), + mint: context.accounts.base_mint.to_account_info(), + to: context.accounts.trader_base.to_account_info(), + authority: context.accounts.market_authority.to_account_info(), + }, + &[authority_seeds], + ), + amount_out, + out_mint_decimals, + )?; + } + Direction::SellBase => { + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.trader_base.to_account_info(), + mint: context.accounts.base_mint.to_account_info(), + to: context.accounts.base_vault.to_account_info(), + authority: context.accounts.trader.to_account_info(), + }, + ), + amount_in, + context.accounts.base_mint.decimals, + )?; + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.quote_vault.to_account_info(), + mint: context.accounts.quote_mint.to_account_info(), + to: context.accounts.trader_quote.to_account_info(), + authority: context.accounts.market_authority.to_account_info(), + }, + &[authority_seeds], + ), + amount_out, + out_mint_decimals, + )?; + } + } + + Ok(()) +} + +#[derive(Accounts)] +pub struct SwapAccountConstraints<'info> { + #[account(mut)] + pub trader: Signer<'info>, + + #[account( + seeds = [MARKET_SEED, market.base_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + has_one = base_mint, + has_one = quote_mint, + has_one = oracle_feed, + has_one = base_vault, + has_one = quote_vault, + )] + pub market: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.key().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount<'info>, + + /// CHECK: validated by the `has_one = oracle_feed` constraint on the market. + pub oracle_feed: UncheckedAccount<'info>, + + pub base_mint: Box>, + + pub quote_mint: Box>, + + #[account( + mut, + seeds = [BASE_VAULT_SEED, market.key().as_ref()], + bump, + )] + pub base_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + )] + pub quote_vault: Box>, + + #[account( + init_if_needed, + payer = trader, + associated_token::mint = base_mint, + associated_token::authority = trader, + associated_token::token_program = token_program, + )] + pub trader_base: Box>, + + #[account( + init_if_needed, + payer = trader, + associated_token::mint = quote_mint, + associated_token::authority = trader, + associated_token::token_program = token_program, + )] + pub trader_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/withdraw_inventory.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/withdraw_inventory.rs new file mode 100644 index 00000000..6caa3649 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/withdraw_inventory.rs @@ -0,0 +1,137 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::{AUTHORITY_SEED, BASE_VAULT_SEED, MARKET_SEED, QUOTE_VAULT_SEED}; +use crate::errors::PropAmmError; +use crate::state::Market; + +/// The operator takes inventory back out — up to every token in both vaults, +/// at any time, with nobody's permission. This is the property that separates +/// a prop AMM from the pooled venues earlier in the companion repository: +/// there are no liquidity-provider shares because there are no liquidity +/// providers. The capital being quoted is the firm's own, so its exit needs no +/// waterfall, no share burn, and no pro-rata math. +pub fn handle_withdraw_inventory( + context: Context, + base_amount: u64, + quote_amount: u64, +) -> Result<()> { + require!( + base_amount > 0 || quote_amount > 0, + PropAmmError::ZeroAmount + ); + require!( + base_amount <= context.accounts.base_vault.amount, + PropAmmError::InsufficientInventory + ); + require!( + quote_amount <= context.accounts.quote_vault.amount, + PropAmmError::InsufficientInventory + ); + + let market = &context.accounts.market; + let market_key = market.key(); + let authority_seeds: &[&[u8]] = &[ + AUTHORITY_SEED, + market_key.as_ref(), + &[market.authority_bump], + ]; + + if base_amount > 0 { + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.base_vault.to_account_info(), + mint: context.accounts.base_mint.to_account_info(), + to: context.accounts.operator_base.to_account_info(), + authority: context.accounts.market_authority.to_account_info(), + }, + &[authority_seeds], + ), + base_amount, + context.accounts.base_mint.decimals, + )?; + } + + if quote_amount > 0 { + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.quote_vault.to_account_info(), + mint: context.accounts.quote_mint.to_account_info(), + to: context.accounts.operator_quote.to_account_info(), + authority: context.accounts.market_authority.to_account_info(), + }, + &[authority_seeds], + ), + quote_amount, + context.accounts.quote_mint.decimals, + )?; + } + + Ok(()) +} + +#[derive(Accounts)] +pub struct WithdrawInventoryAccountConstraints<'info> { + #[account(mut)] + pub operator: Signer<'info>, + + #[account( + seeds = [MARKET_SEED, market.base_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + has_one = operator, + has_one = base_mint, + has_one = quote_mint, + has_one = base_vault, + has_one = quote_vault, + )] + pub market: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.key().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount<'info>, + + pub base_mint: Box>, + + pub quote_mint: Box>, + + #[account( + mut, + seeds = [BASE_VAULT_SEED, market.key().as_ref()], + bump, + )] + pub base_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + )] + pub quote_vault: Box>, + + #[account( + mut, + associated_token::mint = base_mint, + associated_token::authority = operator, + associated_token::token_program = token_program, + )] + pub operator_base: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = operator, + associated_token::token_program = token_program, + )] + pub operator_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/lib.rs b/finance/prop-amm/anchor/programs/prop-amm/src/lib.rs new file mode 100644 index 00000000..0706dfb4 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/lib.rs @@ -0,0 +1,83 @@ +use anchor_lang::prelude::*; + +mod constants; +mod errors; +// Public so the LiteSVM integration tests can build instruction arguments +// (`MarketParameters`, `Direction`) against the program's own types. +pub mod instructions; +pub mod quote_math; +pub mod state; + +use instructions::*; +use state::Direction; + +declare_id!("9ZMtJFtn5n4wwpEeXXG5paFQakcDtrd3ova5ptJL4VT1"); + +/// An oracle-quoted proprietary AMM ("prop AMM"). +/// +/// One operator — a market-making firm — funds a market's inventory with its +/// own capital and quotes both sides of it: anyone may buy the base token at +/// the oracle price plus a spread, or sell it at the oracle price minus the +/// spread. There is no pricing curve, there are no liquidity providers, and +/// there are no shares: the operator is the only capital in the pool, which is +/// the property that gives the design its name. This is the architecture +/// behind venues like Lifinity, SolFi, and HumidiFi, which fill most Solana +/// swaps via aggregator routing. +#[program] +pub mod prop_amm { + use super::*; + + /// Create a market for one base/quote pair, priced by one oracle feed. + /// The signer becomes the market's operator: the only party allowed to + /// move inventory or change the quote. + pub fn initialize_market( + context: Context, + parameters: MarketParameters, + ) -> Result<()> { + instructions::handle_initialize_market(context, parameters) + } + + /// Operator moves inventory into the market's vaults. Either amount may be + /// zero, but not both. + pub fn deposit_inventory( + context: Context, + base_amount: u64, + quote_amount: u64, + ) -> Result<()> { + instructions::handle_deposit_inventory(context, base_amount, quote_amount) + } + + /// Operator withdraws inventory from the market's vaults — up to all of + /// it, at any time. The capital is the operator's own; nobody else has a + /// claim on it. + pub fn withdraw_inventory( + context: Context, + base_amount: u64, + quote_amount: u64, + ) -> Result<()> { + instructions::handle_withdraw_inventory(context, base_amount, quote_amount) + } + + /// Operator re-quotes the market: a new spread, or a pause. Pulling quotes + /// during volatility is not an emergency measure for a market maker; it is + /// Tuesday. + pub fn set_quote( + context: Context, + spread_bps: u16, + paused: bool, + ) -> Result<()> { + instructions::handle_set_quote(context, spread_bps, paused) + } + + /// Swap against the operator's quote: buy the base token at oracle plus + /// spread, or sell it at oracle minus spread. Permissionless. + /// `minimum_amount_out` is slippage protection; pass `0` to opt out. + pub fn swap( + context: Context, + direction: Direction, + amount_in: u64, + minimum_amount_out: u64, + ) -> Result<()> { + instructions::handle_swap(context, direction, amount_in, minimum_amount_out) + } +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/quote_math.rs b/finance/prop-amm/anchor/programs/prop-amm/src/quote_math.rs new file mode 100644 index 00000000..849e1c30 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/quote_math.rs @@ -0,0 +1,141 @@ +//! The market's pure pricing arithmetic, separated from account handling so it +//! can be unit-tested and model-checked (see `finance/prop-amm/kani-proofs`) +//! without the Solana machinery. +//! +//! Every function returns `None` on the paths the program maps to +//! `PropAmmError::MathOverflow`. Rounding always favors the market: the ask is +//! rounded up, the bid is rounded down, and both output amounts floor. The +//! trader-favoring direction never appears, so the invariant "output value at +//! the oracle price never exceeds input value" holds by construction — and the +//! swap handler still asserts it after the fact. +//! +//! Unit conventions: `oracle_price` is quote tokens per whole base token, as a +//! fixed-point integer with `oracle_scale` decimals (e.g. scale 8 makes $165 +//! into 165 * 10^8). Amounts in and out are minor units of their own token, +//! with `base_decimals` / `quote_decimals` decimal places. Nothing assumes the +//! two tokens have the same decimals. + +/// Basis-point denominator, mirroring `constants::BASIS_POINTS_DENOMINATOR`. +const BASIS_POINTS: u128 = 10_000; + +/// The price a buyer of the base token pays: oracle plus the spread, rounded +/// UP so the rounding penny goes to the market, not the buyer. +pub fn ask_price(oracle_price: u64, spread_bps: u16) -> Option { + let numerator = + (oracle_price as u128).checked_mul(BASIS_POINTS.checked_add(spread_bps as u128)?)?; + Some(numerator.div_ceil(BASIS_POINTS)) +} + +/// The price a seller of the base token receives: oracle minus the spread, +/// rounded DOWN — the same coin, the other face. +pub fn bid_price(oracle_price: u64, spread_bps: u16) -> Option { + let numerator = + (oracle_price as u128).checked_mul(BASIS_POINTS.checked_sub(spread_bps as u128)?)?; + Some(numerator / BASIS_POINTS) +} + +/// Base tokens out for `quote_in` at the ask, floored. +/// +/// Derivation: whole base out = (quote_in / 10^quote_decimals) / (ask / 10^scale), +/// then scaled to base minor units by 10^base_decimals. Multiply everything +/// before the one division so the floor happens exactly once, at the end. +pub fn base_out_for_quote_in( + quote_in: u64, + ask: u128, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Option { + let numerator = (quote_in as u128) + .checked_mul(10u128.checked_pow(oracle_scale)?)? + .checked_mul(10u128.checked_pow(base_decimals as u32)?)?; + let denominator = ask.checked_mul(10u128.checked_pow(quote_decimals as u32)?)?; + if denominator == 0 { + return None; + } + u64::try_from(numerator / denominator).ok() +} + +/// Quote tokens out for `base_in` at the bid, floored. +pub fn quote_out_for_base_in( + base_in: u64, + bid: u128, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Option { + let numerator = (base_in as u128) + .checked_mul(bid)? + .checked_mul(10u128.checked_pow(quote_decimals as u32)?)?; + let denominator = + 10u128.checked_pow(oracle_scale)?.checked_mul(10u128.checked_pow(base_decimals as u32)?)?; + if denominator == 0 { + return None; + } + u64::try_from(numerator / denominator).ok() +} + +/// Both sides of a swap valued in the same fixed-point unit +/// (quote minor units × 10^scale × 10^base_decimals), cross-multiplied so no +/// division — and therefore no second rounding — is involved. `None` on +/// overflow. +fn values_at_oracle( + base_amount: u64, + quote_amount: u64, + oracle_price: u64, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Option<(u128, u128)> { + let base_value = (base_amount as u128) + .checked_mul(oracle_price as u128)? + .checked_mul(10u128.checked_pow(quote_decimals as u32)?)?; + let quote_value = (quote_amount as u128) + .checked_mul(10u128.checked_pow(oracle_scale)?)? + .checked_mul(10u128.checked_pow(base_decimals as u32)?)?; + Some((base_value, quote_value)) +} + +/// The swap handler's post-math invariant for a buy: the base tokens handed +/// out must be worth no more, at the raw oracle price (no spread), than the +/// quote tokens taken in. Whatever the quoting arithmetic above produced, the +/// market never pays out more value than it received. +pub fn buy_respects_oracle_value( + quote_in: u64, + base_out: u64, + oracle_price: u64, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Option { + let (base_value, quote_value) = values_at_oracle( + base_out, + quote_in, + oracle_price, + oracle_scale, + base_decimals, + quote_decimals, + )?; + Some(base_value <= quote_value) +} + +/// The same invariant for a sell: the quote tokens handed out must be worth no +/// more, at the raw oracle price, than the base tokens taken in. +pub fn sell_respects_oracle_value( + base_in: u64, + quote_out: u64, + oracle_price: u64, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Option { + let (base_value, quote_value) = values_at_oracle( + base_in, + quote_out, + oracle_price, + oracle_scale, + base_decimals, + quote_decimals, + )?; + Some(quote_value <= base_value) +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/state/market.rs b/finance/prop-amm/anchor/programs/prop-amm/src/state/market.rs new file mode 100644 index 00000000..8aac7c7b --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/state/market.rs @@ -0,0 +1,70 @@ +use anchor_lang::prelude::*; + +/// Which side of the quote a swap takes. +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum Direction { + /// Spend the quote token, receive the base token, priced at the ask + /// (oracle plus spread). + BuyBase, + /// Spend the base token, receive the quote token, priced at the bid + /// (oracle minus spread). + SellBase, +} + +/// One quoted market: a base/quote token pair priced by one oracle feed, with +/// inventory owned entirely by one operator. +/// +/// Note what this account does NOT hold, compared to a curve AMM's pool: no +/// liquidity-provider mint, no fee ledger, no reserves that pricing depends +/// on. The price comes from the oracle; the vault balances only bound how much +/// of a fill is possible. And because nobody but the operator has a claim on +/// the vaults, the token balances themselves are the complete accounting. +#[account] +#[derive(InitSpace)] +pub struct Market { + /// The market-making firm. Deposits and withdraws inventory, sets the + /// spread, pauses quoting. Cannot touch anyone else's funds, because the + /// market never holds anyone else's funds. + pub operator: Pubkey, + + pub base_mint: Pubkey, + + pub quote_mint: Pubkey, + + /// Oracle feed this market quotes from. Stored so handlers can reject any + /// substituted feed account. + pub oracle_feed: Pubkey, + + pub base_vault: Pubkey, + + pub quote_vault: 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, + + /// Decimals of the two mints, pinned at creation so the quote math never + /// has to trust a passed-in mint account for them. + pub base_decimals: u8, + + pub quote_decimals: u8, + + /// Half-spread in basis points: the ask is the oracle price plus this, the + /// bid is the oracle price minus it. The spread is the operator's entire + /// revenue — there is no separate fee. + pub spread_bps: u16, + + /// Maximum oracle confidence band, in basis points of the price, that the + /// market will quote against. A wider band is rejected as untrustworthy. + pub max_confidence_bps: u16, + + /// True while the operator has pulled its quotes. Swaps are rejected; + /// inventory operations still work. + pub paused: bool, + + pub bump: u8, + + /// Bump for the vault authority PDA, stored so CPIs can sign without + /// re-deriving it. + pub authority_bump: u8, +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/state/mod.rs b/finance/prop-amm/anchor/programs/prop-amm/src/state/mod.rs new file mode 100644 index 00000000..cf691455 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/state/mod.rs @@ -0,0 +1,4 @@ +pub mod market; +pub mod oracle; + +pub use market::*; diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs b/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs new file mode 100644 index 00000000..09c6ed94 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs @@ -0,0 +1,101 @@ +use anchor_lang::prelude::*; + +use crate::constants::{BASIS_POINTS_DENOMINATOR, MAX_PRICE_STALENESS_SLOTS}; +use crate::errors::PropAmmError; + +// 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, and for a +// market maker it is existential: quoting a tight spread around a price the +// oracle itself is unsure of is how inventory walks out the door. +// +// The feed account's owning program is NOT checked here: the market trusts +// whatever feed address its operator configured, which is inside the trust +// model (the operator quotes its own capital against its own oracle choice; a +// bad feed loses the operator's money, not the traders'). A production reader +// must still verify the account owner is the oracle program, which +// `parse_and_verify` does. +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 market'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 market'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, + PropAmmError::OracleDataTooShort + ); + + let price = i128::from_le_bytes( + data[PRICE_OFFSET..PRICE_OFFSET + 16] + .try_into() + .map_err(|_| PropAmmError::OracleDataTooShort)?, + ); + let scale = u32::from_le_bytes( + data[SCALE_OFFSET..SCALE_OFFSET + 4] + .try_into() + .map_err(|_| PropAmmError::OracleDataTooShort)?, + ); + let last_update_slot = u64::from_le_bytes( + data[LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8] + .try_into() + .map_err(|_| PropAmmError::OracleDataTooShort)?, + ); + let confidence = u64::from_le_bytes( + data[CONFIDENCE_OFFSET..CONFIDENCE_OFFSET + 8] + .try_into() + .map_err(|_| PropAmmError::OracleDataTooShort)?, + ); + + require!(price > 0, PropAmmError::NonPositivePrice); + require_eq!(scale, expected_scale, PropAmmError::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, + PropAmmError::StalePrice + ); + + // Reject an untrustworthy price: confidence band as a fraction of price, + // in basis points, must not exceed the market'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(PropAmmError::MathOverflow)? + .checked_div(price as u128) + .ok_or(PropAmmError::MathOverflow)?; + require!( + confidence_bps <= max_confidence_bps as u128, + PropAmmError::OracleConfidenceTooWide + ); + + let price: u64 = price.try_into().map_err(|_| PropAmmError::MathOverflow)?; + Ok(price) +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs new file mode 100644 index 00000000..6835746c --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs @@ -0,0 +1,716 @@ +use { + anchor_lang::{ + solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, + AccountDeserialize, InstructionData, ToAccountMetas, + }, + litesvm::LiteSVM, + prop_amm::{ + instructions::initialize_market::MarketParameters, + state::{Direction, Market as MarketState}, + }, + 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, +}; + +// Both tokens have 6 decimals: the base is NVDAx (tokenized NVIDIA stock) and +// the quote is USDC, so one whole unit of either is 1_000_000 minor units. +const ONE_TOKEN: u64 = 1_000_000; +const DECIMALS: u8 = 6; + +// The oracle quotes prices with 8 decimals, so $165 is 165 * 10^8. +const ORACLE_SCALE: u32 = 8; + +// 10 basis points each side of the oracle price. +const SPREAD_BPS: u16 = 10; +const MAX_CONFIDENCE_BPS: u16 = 100; + +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, + operator: Keypair, + operator_base: Pubkey, + operator_quote: Pubkey, + base_mint: Pubkey, + quote_mint: Pubkey, + feed: Pubkey, + market: Pubkey, + market_authority: Pubkey, + base_vault: Pubkey, + quote_vault: Pubkey, +} + +impl Market { + /// Stand up a market at the given starting oracle price. The operator is + /// also the oracle feed authority and holds funded inventory accounts. + fn new(initial_price: i128) -> Market { + let parameters = MarketParameters { + oracle_scale: ORACLE_SCALE, + spread_bps: SPREAD_BPS, + max_confidence_bps: MAX_CONFIDENCE_BPS, + }; + Market::try_new(initial_price, parameters).expect("market initialization should succeed") + } + + /// Like `new`, but takes the full parameter set and surfaces an + /// `initialize_market` rejection instead of panicking, so tests can probe + /// the parameter validation. + fn try_new(initial_price: i128, parameters: MarketParameters) -> Result { + let mut svm = LiteSVM::new(); + svm.add_program( + prop_amm::id(), + include_bytes!("../../../target/deploy/prop_amm.so"), + ) + .unwrap(); + // Use std::fs::read() instead of include_bytes!() for the switchboard program because + // include_bytes!() runs at compile time, and during `anchor build` the IDL generation + // step compiles tests before the .so files exist. Since this is a cross-program + // dependency (not our own program), mock_switchboard.so may not be built yet at compile time. + let switchboard_bytes = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../target/deploy/mock_switchboard.so" + )) + .expect("mock_switchboard.so not found - run `anchor build` first"); + svm.add_program(mock_switchboard::id(), &switchboard_bytes) + .unwrap(); + + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let operator = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let base_mint = create_token_mint(&mut svm, &operator, DECIMALS, None).unwrap(); + let quote_mint = create_token_mint(&mut svm, &operator, DECIMALS, None).unwrap(); + + // Create the mock oracle feed as a fresh account owned by the mock + // program; the operator 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: operator.pubkey(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut svm, + vec![initialize_feed], + &[&operator, &feed_keypair], + &operator.pubkey(), + ) + .unwrap(); + let feed = feed_keypair.pubkey(); + + let market = Pubkey::find_program_address( + &[b"market", base_mint.as_ref(), quote_mint.as_ref()], + &prop_amm::id(), + ) + .0; + let market_authority = + Pubkey::find_program_address(&[b"authority", market.as_ref()], &prop_amm::id()).0; + let base_vault = + Pubkey::find_program_address(&[b"base_vault", market.as_ref()], &prop_amm::id()).0; + let quote_vault = + Pubkey::find_program_address(&[b"quote_vault", market.as_ref()], &prop_amm::id()).0; + + let initialize_market = Instruction::new_with_bytes( + prop_amm::id(), + &prop_amm::instruction::InitializeMarket { parameters }.data(), + prop_amm::accounts::InitializeMarketAccountConstraints { + operator: operator.pubkey(), + market, + base_mint, + quote_mint, + oracle_feed: feed, + market_authority, + base_vault, + quote_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_market], + &[&operator], + &operator.pubkey(), + ) + .map_err(|_| ())?; + + // Fund the operator's inventory accounts. + let operator_base = create_associated_token_account( + &mut svm, + &operator.pubkey(), + &base_mint, + &payer, + ) + .unwrap(); + let operator_quote = create_associated_token_account( + &mut svm, + &operator.pubkey(), + "e_mint, + &payer, + ) + .unwrap(); + mint_tokens_to_token_account( + &mut svm, + &base_mint, + &operator_base, + 10_000 * ONE_TOKEN, + &operator, + ) + .unwrap(); + mint_tokens_to_token_account( + &mut svm, + "e_mint, + &operator_quote, + 10_000_000 * ONE_TOKEN, + &operator, + ) + .unwrap(); + + Ok(Market { + svm, + payer, + operator, + operator_base, + operator_quote, + base_mint, + quote_mint, + feed, + market, + market_authority, + base_vault, + quote_vault, + }) + } + + /// A market at $165 stocked with 1,000 NVDAx and 200,000 USDC. + fn default_market() -> Market { + let mut market = Market::new(dollars(165)); + market + .deposit_inventory(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN) + .unwrap(); + market + } + + fn market_state(&self) -> MarketState { + let account = self.svm.get_account(&self.market).unwrap(); + MarketState::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.operator.pubkey(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![set_price], + &[&self.operator], + &self.operator.pubkey(), + ) + .unwrap(); + } + + fn warp(&mut self, slot: u64) { + self.svm.warp_to_slot(slot); + self.svm.expire_blockhash(); + } + + /// Create a wallet holding `base` and `quote` minor units in associated + /// token accounts. + fn funded_trader(&mut self, base: u64, quote: u64) -> (Keypair, Pubkey, Pubkey) { + let trader = create_wallet(&mut self.svm, 100_000_000_000).unwrap(); + let base_account = create_associated_token_account( + &mut self.svm, + &trader.pubkey(), + &self.base_mint, + &self.payer, + ) + .unwrap(); + let quote_account = create_associated_token_account( + &mut self.svm, + &trader.pubkey(), + &self.quote_mint, + &self.payer, + ) + .unwrap(); + if base > 0 { + mint_tokens_to_token_account( + &mut self.svm, + &self.base_mint, + &base_account, + base, + &self.operator, + ) + .unwrap(); + } + if quote > 0 { + mint_tokens_to_token_account( + &mut self.svm, + &self.quote_mint, + "e_account, + quote, + &self.operator, + ) + .unwrap(); + } + (trader, base_account, quote_account) + } + + /// Inventory movement signed by `signer` (the operator in honest tests, an + /// imposter in the access-control tests). + fn move_inventory_as( + &mut self, + signer: &Keypair, + deposit: bool, + base_amount: u64, + quote_amount: u64, + ) -> Result<(), ()> { + let signer_base = derive_ata(&signer.pubkey(), &self.base_mint); + let signer_quote = derive_ata(&signer.pubkey(), &self.quote_mint); + let instruction = if deposit { + Instruction::new_with_bytes( + prop_amm::id(), + &prop_amm::instruction::DepositInventory { + base_amount, + quote_amount, + } + .data(), + prop_amm::accounts::DepositInventoryAccountConstraints { + operator: signer.pubkey(), + market: self.market, + base_mint: self.base_mint, + quote_mint: self.quote_mint, + base_vault: self.base_vault, + quote_vault: self.quote_vault, + operator_base: signer_base, + operator_quote: signer_quote, + token_program: token_program_id(), + } + .to_account_metas(None), + ) + } else { + Instruction::new_with_bytes( + prop_amm::id(), + &prop_amm::instruction::WithdrawInventory { + base_amount, + quote_amount, + } + .data(), + prop_amm::accounts::WithdrawInventoryAccountConstraints { + operator: signer.pubkey(), + market: self.market, + market_authority: self.market_authority, + base_mint: self.base_mint, + quote_mint: self.quote_mint, + base_vault: self.base_vault, + quote_vault: self.quote_vault, + operator_base: signer_base, + operator_quote: signer_quote, + token_program: token_program_id(), + } + .to_account_metas(None), + ) + }; + send_transaction_from_instructions(&mut self.svm, vec![instruction], &[signer], &signer.pubkey()) + .map(|_| ()) + .map_err(|_| ()) + } + + fn deposit_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), ()> { + let operator = self.operator.insecure_clone(); + self.move_inventory_as(&operator, true, base_amount, quote_amount) + } + + fn withdraw_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), ()> { + let operator = self.operator.insecure_clone(); + self.move_inventory_as(&operator, false, base_amount, quote_amount) + } + + fn set_quote_as(&mut self, signer: &Keypair, spread_bps: u16, paused: bool) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + prop_amm::id(), + &prop_amm::instruction::SetQuote { spread_bps, paused }.data(), + prop_amm::accounts::SetQuoteAccountConstraints { + operator: signer.pubkey(), + market: self.market, + } + .to_account_metas(None), + ); + send_transaction_from_instructions(&mut self.svm, vec![instruction], &[signer], &signer.pubkey()) + .map(|_| ()) + .map_err(|_| ()) + } + + fn set_quote(&mut self, spread_bps: u16, paused: bool) -> Result<(), ()> { + let operator = self.operator.insecure_clone(); + self.set_quote_as(&operator, spread_bps, paused) + } + + fn swap( + &mut self, + trader: &Keypair, + direction: Direction, + amount_in: u64, + minimum_amount_out: u64, + ) -> Result<(), ()> { + let trader_base = derive_ata(&trader.pubkey(), &self.base_mint); + let trader_quote = derive_ata(&trader.pubkey(), &self.quote_mint); + let instruction = Instruction::new_with_bytes( + prop_amm::id(), + &prop_amm::instruction::Swap { + direction, + amount_in, + minimum_amount_out, + } + .data(), + prop_amm::accounts::SwapAccountConstraints { + trader: trader.pubkey(), + market: self.market, + market_authority: self.market_authority, + oracle_feed: self.feed, + base_mint: self.base_mint, + quote_mint: self.quote_mint, + base_vault: self.base_vault, + quote_vault: self.quote_vault, + trader_base, + trader_quote, + 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 balance(&self, token_account: &Pubkey) -> u64 { + get_token_account_balance(&self.svm, token_account).unwrap() + } +} + +// =========================================================================== +// Happy paths: exact quote math in both directions +// =========================================================================== + +/// Alice buys 10 NVDAx. At $165 with a 10 bps spread the ask is $165.165, so +/// 10 NVDAx costs exactly 1,651.65 USDC. +#[test] +fn test_swap_buys_base_at_the_ask() { + let mut market = Market::default_market(); + let quote_in = 1_651_650_000; // 1,651.65 USDC + let (alice, alice_base, alice_quote) = market.funded_trader(0, quote_in); + + market + .swap(&alice, Direction::BuyBase, quote_in, 10 * ONE_TOKEN) + .unwrap(); + + assert_eq!(market.balance(&alice_base), 10 * ONE_TOKEN); + assert_eq!(market.balance(&alice_quote), 0); + // Conservation: the vaults moved by exactly the two legs of the fill. + assert_eq!(market.balance(&market.base_vault), 990 * ONE_TOKEN); + assert_eq!( + market.balance(&market.quote_vault), + 200_000 * ONE_TOKEN + quote_in + ); +} + +/// Bob sells 10 NVDAx. At $165 with a 10 bps spread the bid is $164.835, so +/// he receives exactly 1,648.35 USDC. +#[test] +fn test_swap_sells_base_at_the_bid() { + let mut market = Market::default_market(); + let (bob, bob_base, bob_quote) = market.funded_trader(10 * ONE_TOKEN, 0); + + market + .swap(&bob, Direction::SellBase, 10 * ONE_TOKEN, 1_648_350_000) + .unwrap(); + + assert_eq!(market.balance(&bob_base), 0); + assert_eq!(market.balance(&bob_quote), 1_648_350_000); // 1,648.35 USDC + assert_eq!(market.balance(&market.base_vault), 1_010 * ONE_TOKEN); + assert_eq!( + market.balance(&market.quote_vault), + 200_000 * ONE_TOKEN - 1_648_350_000 + ); +} + +/// A buy immediately followed by a sell of the same 10 NVDAx costs exactly the +/// round-trip spread: 3.30 USDC on a $1,650 position, all of which stays in +/// the market's inventory. The spread IS the fee; there is no other one. +#[test] +fn test_round_trip_costs_exactly_the_spread() { + let mut market = Market::default_market(); + let quote_in = 1_651_650_000; + let (carol, carol_base, carol_quote) = market.funded_trader(0, quote_in); + + market + .swap(&carol, Direction::BuyBase, quote_in, 0) + .unwrap(); + market + .swap(&carol, Direction::SellBase, 10 * ONE_TOKEN, 0) + .unwrap(); + + assert_eq!(market.balance(&carol_base), 0); + // 1,651.65 in, 1,648.35 back: the market kept 3.30 USDC. + assert_eq!(market.balance(&carol_quote), quote_in - 3_300_000); + assert_eq!(market.balance(&market.base_vault), 1_000 * ONE_TOKEN); + assert_eq!( + market.balance(&market.quote_vault), + 200_000 * ONE_TOKEN + 3_300_000 + ); +} + +/// When the oracle reprices, the quote follows instantly — no trade has to +/// drag the price there through a curve. At $170 the ask is $170.17, so 10 +/// NVDAx costs exactly 1,701.70 USDC. +#[test] +fn test_quote_follows_the_oracle() { + let mut market = Market::default_market(); + market.set_price(dollars(170)); + + let quote_in = 1_701_700_000; // 1,701.70 USDC + let (alice, alice_base, _) = market.funded_trader(0, quote_in); + market + .swap(&alice, Direction::BuyBase, quote_in, 10 * ONE_TOKEN) + .unwrap(); + + assert_eq!(market.balance(&alice_base), 10 * ONE_TOKEN); +} + +/// The operator re-quotes to a 50 bps spread; the next fill prices at +/// $165.825, so 10 NVDAx costs exactly 1,658.25 USDC. +#[test] +fn test_set_quote_changes_the_spread() { + let mut market = Market::default_market(); + market.set_quote(50, false).unwrap(); + assert_eq!(market.market_state().spread_bps, 50); + + let quote_in = 1_658_250_000; // 1,658.25 USDC + let (alice, alice_base, _) = market.funded_trader(0, quote_in); + market + .swap(&alice, Direction::BuyBase, quote_in, 10 * ONE_TOKEN) + .unwrap(); + + assert_eq!(market.balance(&alice_base), 10 * ONE_TOKEN); +} + +// =========================================================================== +// The operator's capital: deposit, withdraw, and the full exit +// =========================================================================== + +/// The operator can withdraw every token in both vaults at any time — its +/// capital, its exit. Afterwards the market still exists but cannot fill, +/// which is exactly what an empty prop AMM should do: reject, not misprice. +#[test] +fn test_operator_can_withdraw_everything_and_swaps_then_fail() { + let mut market = Market::default_market(); + market + .withdraw_inventory(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN) + .unwrap(); + + assert_eq!(market.balance(&market.base_vault), 0); + assert_eq!(market.balance(&market.quote_vault), 0); + let operator_base = market.operator_base; + let operator_quote = market.operator_quote; + assert_eq!(market.balance(&operator_base), 10_000 * ONE_TOKEN); + assert_eq!(market.balance(&operator_quote), 10_000_000 * ONE_TOKEN); + + let (alice, _, _) = market.funded_trader(0, 1_651_650_000); + assert!(market + .swap(&alice, Direction::BuyBase, 1_651_650_000, 0) + .is_err()); +} + +#[test] +fn test_withdraw_more_than_inventory_fails() { + let mut market = Market::default_market(); + assert!(market + .withdraw_inventory(1_001 * ONE_TOKEN, 0) + .is_err()); +} + +#[test] +fn test_deposit_inventory_rejects_non_operator() { + let mut market = Market::default_market(); + let (mallory, _, _) = market.funded_trader(ONE_TOKEN, ONE_TOKEN); + assert!(market + .move_inventory_as(&mallory, true, ONE_TOKEN, 0) + .is_err()); +} + +#[test] +fn test_withdraw_inventory_rejects_non_operator() { + let mut market = Market::default_market(); + let (mallory, _, _) = market.funded_trader(0, 0); + assert!(market + .move_inventory_as(&mallory, false, ONE_TOKEN, 0) + .is_err()); +} + +#[test] +fn test_set_quote_rejects_non_operator() { + let mut market = Market::default_market(); + let (mallory, _, _) = market.funded_trader(0, 0); + assert!(market.set_quote_as(&mallory, 500, true).is_err()); +} + +// =========================================================================== +// Swap rejections: every gate has a test that proves it shuts +// =========================================================================== + +/// A fill below the caller's minimum is rejected, not filled worse. +#[test] +fn test_swap_rejects_slippage() { + let mut market = Market::default_market(); + let quote_in = 1_651_650_000; + let (alice, _, _) = market.funded_trader(0, quote_in); + // The fill would be exactly 10 NVDAx; demand one minor unit more. + assert!(market + .swap(&alice, Direction::BuyBase, quote_in, 10 * ONE_TOKEN + 1) + .is_err()); +} + +/// An oracle price older than the staleness bound cannot be traded against. +/// A lagging quote is a free option for arbitrageurs, so the market refuses to +/// quote at all rather than quote wrong. +#[test] +fn test_swap_rejects_stale_price() { + let mut market = Market::default_market(); + let (alice, _, _) = market.funded_trader(0, 1_651_650_000); + // The feed was last updated near slot 0; 200 slots later it is stale + // (the bound is 150 slots). + market.warp(200); + assert!(market + .swap(&alice, Direction::BuyBase, 1_651_650_000, 0) + .is_err()); +} + +/// A price the oracle itself is unsure about is rejected: the confidence band +/// (about 1.2% here) exceeds the market's 1% limit. +#[test] +fn test_swap_rejects_wide_confidence() { + let mut market = Market::default_market(); + market.set_price_with_confidence(dollars(165), 200_000_000); + let (alice, _, _) = market.funded_trader(0, 1_651_650_000); + assert!(market + .swap(&alice, Direction::BuyBase, 1_651_650_000, 0) + .is_err()); +} + +/// While the operator has pulled its quotes, nobody can swap. +#[test] +fn test_swap_rejects_when_paused() { + let mut market = Market::default_market(); + market.set_quote(SPREAD_BPS, true).unwrap(); + let (alice, _, _) = market.funded_trader(0, 1_651_650_000); + assert!(market + .swap(&alice, Direction::BuyBase, 1_651_650_000, 0) + .is_err()); + + // Unpausing restores the exact same quote. + market.set_quote(SPREAD_BPS, false).unwrap(); + market + .swap(&alice, Direction::BuyBase, 1_651_650_000, 10 * ONE_TOKEN) + .unwrap(); +} + +#[test] +fn test_swap_rejects_zero_amount() { + let mut market = Market::default_market(); + let (alice, _, _) = market.funded_trader(0, ONE_TOKEN); + assert!(market.swap(&alice, Direction::BuyBase, 0, 0).is_err()); +} + +/// A buy bigger than the base inventory is rejected whole — a prop AMM never +/// partially fills, and never prices what it cannot deliver. +#[test] +fn test_swap_rejects_insufficient_inventory() { + let mut market = Market::default_market(); + // 1,100 NVDAx at $165.165 ≈ 181,681.50 USDC — affordable for the trader, + // but the vault only holds 1,000 NVDAx. + let quote_in = 181_681_500_000; + let (whale, _, _) = market.funded_trader(0, quote_in); + assert!(market.swap(&whale, Direction::BuyBase, quote_in, 0).is_err()); +} + +// =========================================================================== +// Parameter validation +// =========================================================================== + +#[test] +fn test_initialize_market_rejects_zero_spread() { + let parameters = MarketParameters { + oracle_scale: ORACLE_SCALE, + spread_bps: 0, + max_confidence_bps: MAX_CONFIDENCE_BPS, + }; + assert!(Market::try_new(dollars(165), parameters).is_err()); +} + +#[test] +fn test_initialize_market_rejects_full_spread() { + let parameters = MarketParameters { + oracle_scale: ORACLE_SCALE, + spread_bps: 10_000, + max_confidence_bps: MAX_CONFIDENCE_BPS, + }; + assert!(Market::try_new(dollars(165), parameters).is_err()); +} + +#[test] +fn test_set_quote_rejects_invalid_spread() { + let mut market = Market::default_market(); + assert!(market.set_quote(0, false).is_err()); + assert!(market.set_quote(10_000, false).is_err()); +} diff --git a/finance/prop-amm/kani-proofs/Cargo.toml b/finance/prop-amm/kani-proofs/Cargo.toml new file mode 100644 index 00000000..a2adfe6b --- /dev/null +++ b/finance/prop-amm/kani-proofs/Cargo.toml @@ -0,0 +1,21 @@ +# Standalone workspace - intentionally NOT part of the root program-examples +# workspace. Kani (https://github.com/model-checking/kani) proof harnesses that +# model the prop AMM's pure quoting arithmetic (ask/bid construction, output +# amounts, the oracle-value invariant) so the model checker can verify the +# invariants without the Solana / SPL-token CPI machinery, which Kani cannot +# symbolically execute. +[workspace] + +[package] +name = "prop-amm-kani-proofs" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" + +[lints.rust] +unexpected_cfgs = { level = "allow", check-cfg = ['cfg(kani)'] } + +[dependencies] diff --git a/finance/prop-amm/kani-proofs/README.md b/finance/prop-amm/kani-proofs/README.md new file mode 100644 index 00000000..73c7d994 --- /dev/null +++ b/finance/prop-amm/kani-proofs/README.md @@ -0,0 +1,58 @@ +# Prop AMM: Kani proofs + +Formal-verification harnesses for the oracle-quoted prop AMM, in the spirit of +[`aeyakovenko/percolator`](https://github.com/aeyakovenko/percolator), which +uses the [Kani](https://github.com/model-checking/kani) model checker to prove +the mathematical correctness of a DeFi engine. + +## What is verified + +The on-chain instructions hand token movement to the SPL token program through +CPIs that Kani cannot symbolically execute, but the *interesting* part — the +ask/bid construction, the amount conversion across oracle scale and token +decimals, and the "never pay out more than oracle value" invariant — is pure +integer arithmetic. This crate reproduces those formulas faithfully (same +`u128` widening, multiply-before-divide, ask ceiled, bid and outputs floored; +mirrors `prop_amm::quote_math`) and proves the invariants: + +- `proof_quote_brackets_oracle`: `bid <= oracle <= ask` for every valid price + and spread, and both roundings are *exact* (the ask is the smallest integer + at or above the true ratio, the bid the largest at or below), so both + under-rounding against the market and over-rounding against the trader fail + the proof. +- `proof_buy_never_exceeds_oracle_value`: **The core safety property**, buy + side: the base handed out is never worth more at the raw oracle price than + the quote taken in. This is exactly the swap handler's post-math + `InvariantViolated` assert — the proof says it can never fire while the + quoting math is intact. +- `proof_sell_never_exceeds_oracle_value`: the same property, sell side. +- `proof_round_trip_never_profits_the_trader`: buying and immediately selling + back returns no more quote than went in, for every price, spread, and + decimal configuration — a trader cannot mint money by bouncing off both + sides of the quote. + +## Bounded model checking + +The output-amount harnesses divide by a *symbolic* ask or bid — symbolic ÷ +symbolic 128-bit division, the hardest case for a bit-precise model checker. +Following percolator's practice, amounts and prices are bounded and the +decimal exponents are kept small; the identities are independent of the +exponents' actual values (they enter both sides of each comparison +symmetrically), so the bounded domain exercises the same rounding edges as +scale 8 with 6-decimal tokens. Spreads stay fully symbolic over their entire +valid range (`1..10_000`). + +- `proof_quote_brackets_oracle`: price fully symbolic (linear arithmetic) +- `proof_buy_never_exceeds_oracle_value` / `proof_sell_never_exceeds_oracle_value`: amounts and price `<= 1023` +- `proof_round_trip_never_profits_the_trader`: amounts and price `<= 255` (two chained symbolic divisions) + +## Running + +```bash +# Plain unit tests (no Kani needed) — also pin the exact numbers the LiteSVM +# tests and the book chapter use: +cargo test + +# Full verification (requires cargo-kani): +cargo kani +``` diff --git a/finance/prop-amm/kani-proofs/src/lib.rs b/finance/prop-amm/kani-proofs/src/lib.rs new file mode 100644 index 00000000..655b6edd --- /dev/null +++ b/finance/prop-amm/kani-proofs/src/lib.rs @@ -0,0 +1,284 @@ +//! Kani proof harnesses for the prop AMM (`finance/prop-amm`). +//! +//! Inspired by aeyakovenko/percolator, which uses the Kani model checker to +//! prove the mathematical correctness of a DeFi engine's pure numeric core. +//! +//! The on-chain instructions hand the actual token movement to the SPL token +//! program via CPIs that Kani cannot symbolically execute. But the +//! *interesting* part — building the ask and bid from the oracle price and the +//! spread, converting between token amounts across decimals, and the +//! "never pay out more than oracle value" invariant — is pure integer +//! arithmetic. This crate reproduces those formulas faithfully (same `u128` +//! widening, same multiply-before-divide, same rounding directions: ask ceils, +//! bid floors, outputs floor) and proves the invariants the program depends +//! on. Formulas mirror `prop_amm::quote_math`. + +#![cfg_attr(kani, allow(dead_code))] + +/// Basis-points denominator (`constants::BASIS_POINTS_DENOMINATOR`). +pub const BASIS_POINTS: u128 = 10_000; + +// =========================================================================== +// 1. The quote: ask and bid (quote_math.rs) +// =========================================================================== + +/// The price a buyer pays: oracle plus the spread, rounded UP. Mirrors +/// `quote_math::ask_price`. +pub fn ask_price(oracle_price: u64, spread_bps: u16) -> Option { + let numerator = + (oracle_price as u128).checked_mul(BASIS_POINTS.checked_add(spread_bps as u128)?)?; + Some(numerator.div_ceil(BASIS_POINTS)) +} + +/// The price a seller receives: oracle minus the spread, rounded DOWN. +/// Mirrors `quote_math::bid_price`. +pub fn bid_price(oracle_price: u64, spread_bps: u16) -> Option { + let numerator = + (oracle_price as u128).checked_mul(BASIS_POINTS.checked_sub(spread_bps as u128)?)?; + Some(numerator / BASIS_POINTS) +} + +/// The quote brackets the oracle: `bid <= oracle <= ask`, with each side's +/// rounding pointing away from the trader. Also proves the roundings are +/// exact: the ask is the *smallest* integer at or above the true ratio (ceil, +/// not "add one"), so a refactor that over-rounds in the market's favor fails +/// the proof too. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_quote_brackets_oracle() { + let price: u64 = kani::any(); + let spread_bps: u16 = kani::any(); + // The full price range is linear arithmetic and stays tractable; the + // spread is validated `1..10_000` by initialize_market / set_quote. + kani::assume(price >= 1); + kani::assume(spread_bps >= 1 && (spread_bps as u128) < BASIS_POINTS); + + let ask = ask_price(price, spread_bps).expect("ask computes"); + let bid = bid_price(price, spread_bps).expect("bid computes"); + + assert!(bid <= price as u128, "bid never exceeds the oracle"); + assert!(ask >= price as u128, "ask never undercuts the oracle"); + assert!(bid <= ask); + + // Exactness of the roundings, both directions: + // ceil: ask*BPS >= price*(BPS+s) and (ask-1)*BPS < price*(BPS+s) + let ask_target = (price as u128) * (BASIS_POINTS + spread_bps as u128); + assert!(ask * BASIS_POINTS >= ask_target); + assert!((ask - 1) * BASIS_POINTS < ask_target); + // floor: bid*BPS <= price*(BPS-s) < (bid+1)*BPS + let bid_target = (price as u128) * (BASIS_POINTS - spread_bps as u128); + assert!(bid * BASIS_POINTS <= bid_target); + assert!((bid + 1) * BASIS_POINTS > bid_target); +} + +// =========================================================================== +// 2. Output amounts (quote_math.rs) +// =========================================================================== + +/// Base tokens out for `quote_in` at the ask, floored. Mirrors +/// `quote_math::base_out_for_quote_in`. +pub fn base_out_for_quote_in( + quote_in: u64, + ask: u128, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Option { + let numerator = (quote_in as u128) + .checked_mul(10u128.checked_pow(oracle_scale)?)? + .checked_mul(10u128.checked_pow(base_decimals as u32)?)?; + let denominator = ask.checked_mul(10u128.checked_pow(quote_decimals as u32)?)?; + if denominator == 0 { + return None; + } + u64::try_from(numerator / denominator).ok() +} + +/// Quote tokens out for `base_in` at the bid, floored. Mirrors +/// `quote_math::quote_out_for_base_in`. +pub fn quote_out_for_base_in( + base_in: u64, + bid: u128, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Option { + let numerator = (base_in as u128) + .checked_mul(bid)? + .checked_mul(10u128.checked_pow(quote_decimals as u32)?)?; + let denominator = + 10u128.checked_pow(oracle_scale)?.checked_mul(10u128.checked_pow(base_decimals as u32)?)?; + if denominator == 0 { + return None; + } + u64::try_from(numerator / denominator).ok() +} + +/// THE core prop-AMM safety property, buy side: the base handed out is never +/// worth more, at the raw oracle price, than the quote taken in — for every +/// price, spread, amount, and decimal configuration. This is exactly the +/// `require!(respects_oracle_value)` assert in the swap handler; the proof +/// says that assert can never fire while the math above it is intact. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_buy_never_exceeds_oracle_value() { + let quote_in: u64 = kani::any(); + let price: u64 = kani::any(); + let spread_bps: u16 = kani::any(); + let oracle_scale: u32 = kani::any(); + let base_decimals: u8 = kani::any(); + let quote_decimals: u8 = kani::any(); + + // Bounded model checking: the division by a *symbolic* ask is the hard + // part for the bit-precise solver. Amounts and price are capped, and the + // decimal exponents are kept small (the identity is independent of the + // exponents' actual values — they enter both sides of the comparison + // symmetrically — so tiny exponents exercise the same rounding edges as + // scale 8 and 6/6 decimals). + kani::assume(quote_in <= 1023); + kani::assume(price >= 1 && price <= 1023); + kani::assume(spread_bps >= 1 && (spread_bps as u128) < BASIS_POINTS); + kani::assume(oracle_scale <= 2); + kani::assume(base_decimals <= 2 && quote_decimals <= 2); + + let ask = ask_price(price, spread_bps).expect("ask computes"); + let base_out = + base_out_for_quote_in(quote_in, ask, oracle_scale, base_decimals, quote_decimals) + .expect("output computes within bounds"); + + // base_out * price * 10^q <= quote_in * 10^S * 10^b + let base_value = (base_out as u128) * (price as u128) * 10u128.pow(quote_decimals as u32); + let quote_value = + (quote_in as u128) * 10u128.pow(oracle_scale) * 10u128.pow(base_decimals as u32); + assert!(base_value <= quote_value, "buy paid out above oracle value"); +} + +/// The same property, sell side: the quote handed out is never worth more, at +/// the raw oracle price, than the base taken in. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_sell_never_exceeds_oracle_value() { + let base_in: u64 = kani::any(); + let price: u64 = kani::any(); + let spread_bps: u16 = kani::any(); + let oracle_scale: u32 = kani::any(); + let base_decimals: u8 = kani::any(); + let quote_decimals: u8 = kani::any(); + + kani::assume(base_in <= 1023); + kani::assume(price >= 1 && price <= 1023); + kani::assume(spread_bps >= 1 && (spread_bps as u128) < BASIS_POINTS); + kani::assume(oracle_scale <= 2); + kani::assume(base_decimals <= 2 && quote_decimals <= 2); + + let bid = bid_price(price, spread_bps).expect("bid computes"); + let quote_out = + quote_out_for_base_in(base_in, bid, oracle_scale, base_decimals, quote_decimals) + .expect("output computes within bounds"); + + let base_value = (base_in as u128) * (price as u128) * 10u128.pow(quote_decimals as u32); + let quote_value = + (quote_out as u128) * 10u128.pow(oracle_scale) * 10u128.pow(base_decimals as u32); + assert!(quote_value <= base_value, "sell paid out above oracle value"); +} + +// =========================================================================== +// 3. The round trip (the spread is the fee) +// =========================================================================== + +/// Buying base and immediately selling all of it back never returns more +/// quote than went in: a trader cannot mint money by bouncing off both sides +/// of the quote, whatever the price, spread, or decimal configuration. (The +/// difference is the round-trip spread — the market's revenue.) +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_round_trip_never_profits_the_trader() { + let quote_in: u64 = kani::any(); + let price: u64 = kani::any(); + let spread_bps: u16 = kani::any(); + let oracle_scale: u32 = kani::any(); + let base_decimals: u8 = kani::any(); + let quote_decimals: u8 = kani::any(); + + // Two symbolic divisions chained — the hardest harness here; keep the + // amount bound tight. + kani::assume(quote_in <= 255); + kani::assume(price >= 1 && price <= 255); + kani::assume(spread_bps >= 1 && (spread_bps as u128) < BASIS_POINTS); + kani::assume(oracle_scale <= 2); + kani::assume(base_decimals <= 2 && quote_decimals <= 2); + + let ask = ask_price(price, spread_bps).expect("ask computes"); + let bid = bid_price(price, spread_bps).expect("bid computes"); + let base_out = + base_out_for_quote_in(quote_in, ask, oracle_scale, base_decimals, quote_decimals) + .expect("buy computes"); + let quote_back = + quote_out_for_base_in(base_out, bid, oracle_scale, base_decimals, quote_decimals) + .expect("sell computes"); + + assert!(quote_back <= quote_in, "round trip must not profit the trader"); +} + +// =========================================================================== +// Plain unit tests (so the crate is meaningful without Kani installed). +// These pin the exact numbers the LiteSVM tests and the book chapter use. +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + // $165 at scale 8; both tokens 6 decimals; 10 bps spread. + const PRICE: u64 = 16_500_000_000; + const SCALE: u32 = 8; + const DEC: u8 = 6; + + #[test] + fn ask_and_bid_at_165() { + assert_eq!(ask_price(PRICE, 10).unwrap(), 16_516_500_000); // $165.165 + assert_eq!(bid_price(PRICE, 10).unwrap(), 16_483_500_000); // $164.835 + } + + #[test] + fn buy_ten_nvdax() { + // 1,651.65 USDC buys exactly 10 NVDAx at the ask. + let ask = ask_price(PRICE, 10).unwrap(); + assert_eq!( + base_out_for_quote_in(1_651_650_000, ask, SCALE, DEC, DEC).unwrap(), + 10_000_000 + ); + } + + #[test] + fn sell_ten_nvdax() { + // 10 NVDAx sells for exactly 1,648.35 USDC at the bid. + let bid = bid_price(PRICE, 10).unwrap(); + assert_eq!( + quote_out_for_base_in(10_000_000, bid, SCALE, DEC, DEC).unwrap(), + 1_648_350_000 + ); + } + + #[test] + fn round_trip_costs_the_spread() { + // In 1,651.65, back 1,648.35: the market keeps exactly 3.30 USDC. + let ask = ask_price(PRICE, 10).unwrap(); + let bid = bid_price(PRICE, 10).unwrap(); + let base = base_out_for_quote_in(1_651_650_000, ask, SCALE, DEC, DEC).unwrap(); + let back = quote_out_for_base_in(base, bid, SCALE, DEC, DEC).unwrap(); + assert_eq!(1_651_650_000 - back, 3_300_000); + } + + #[test] + fn ask_ceils_and_bid_floors() { + // price 999, spread 1 bps: 999 * 10_001 = 9_990_999 -> ask ceil 1_000, + // 999 * 9_999 = 9_989_001 -> bid floor 998. + assert_eq!(ask_price(999, 1).unwrap(), 1_000); + assert_eq!(bid_price(999, 1).unwrap(), 998); + } +} diff --git a/finance/prop-amm/quasar/.gitignore b/finance/prop-amm/quasar/.gitignore new file mode 100644 index 00000000..3dcc0425 --- /dev/null +++ b/finance/prop-amm/quasar/.gitignore @@ -0,0 +1,5 @@ +target +**/*.rs.bk +node_modules +test-ledger +.DS_Store diff --git a/finance/prop-amm/quasar/CHANGELOG.md b/finance/prop-amm/quasar/CHANGELOG.md new file mode 100644 index 00000000..202c1aff --- /dev/null +++ b/finance/prop-amm/quasar/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## 2026-07-11 + +Initial version: Quasar port of the prop-amm example, matching the Anchor +sibling's design and math. diff --git a/finance/prop-amm/quasar/Cargo.toml b/finance/prop-amm/quasar/Cargo.toml new file mode 100644 index 00000000..49f0acd6 --- /dev/null +++ b/finance/prop-amm/quasar/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "quasar-prop-amm" +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] +# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime +# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev +# pinned above (needs solana-address <2.6). cb7565d is the last rev before the +# bump — the same environment the other Quasar examples' CI runs built in. +# Unpin together with the quasar-lang pin once upstream is consistent again. +quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } +spl-token-interface = { version = "2.0.0" } +solana-program-pack = { version = "3.1.0" } diff --git a/finance/prop-amm/quasar/Quasar.toml b/finance/prop-amm/quasar/Quasar.toml new file mode 100644 index 00000000..6143b52e --- /dev/null +++ b/finance/prop-amm/quasar/Quasar.toml @@ -0,0 +1,22 @@ +[project] +name = "quasar_prop_amm" + +[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/prop-amm/quasar/README.md b/finance/prop-amm/quasar/README.md new file mode 100644 index 00000000..0d3b5970 --- /dev/null +++ b/finance/prop-amm/quasar/README.md @@ -0,0 +1,39 @@ +# Prop AMM (Quasar) + +A [Quasar](https://quasar-lang.com/docs) port of the prop-amm example. The +design, math, and behaviour match the Anchor implementation at +[`../anchor`](../anchor). Read that README for the full walkthrough of the +oracle-quoted, operator-owned model. This page only covers what differs in the +Quasar version. + +## Differences from the Anchor version + +- **`Direction` is a `u8`.** Quasar instruction arguments are plain integers, + so the Anchor sibling's `Direction` enum becomes `0` (buy base at the ask) + or `1` (sell base at the bid), with named constants in `constants.rs`. +- **`paused` is a `u8`.** The account layout is zero-copy, so the flag is + `0`/`1` rather than a `bool`. +- **Trader token accounts must already exist.** The Anchor version uses + `init_if_needed` to create the trader's destination account inside the swap; + here the tests create both token accounts up front. +- **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, confidence) 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 both mints, an oracle feed at $165, and an +operator with funded inventory, then verify the quote math to the minor unit +in both directions, the exact 3.30 USDC round-trip spread, oracle repricing +and re-quoting, the operator's full exit, and that every gate shuts: slippage, +staleness, confidence, pause, zero amounts, inventory bounds, and operator +access control. + +```bash +quasar build +cargo test tests:: +``` diff --git a/finance/prop-amm/quasar/src/constants.rs b/finance/prop-amm/quasar/src/constants.rs new file mode 100644 index 00000000..3ec12bbf --- /dev/null +++ b/finance/prop-amm/quasar/src/constants.rs @@ -0,0 +1,12 @@ +/// Basis-point denominator: 100% = 10_000 bps. +pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; + +/// Reject an oracle price older than this many slots (~1 minute at 400ms). +/// For a market maker the bound is the business itself: a stale quote is a +/// free option for whoever notices first. +pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; + +/// `direction` argument values for `swap`. Quasar instruction arguments are +/// plain integers, so the Anchor sibling's `Direction` enum becomes a `u8`. +pub const DIRECTION_BUY_BASE: u8 = 0; +pub const DIRECTION_SELL_BASE: u8 = 1; diff --git a/finance/prop-amm/quasar/src/instructions/deposit_inventory.rs b/finance/prop-amm/quasar/src/instructions/deposit_inventory.rs new file mode 100644 index 00000000..b28bd4fb --- /dev/null +++ b/finance/prop-amm/quasar/src/instructions/deposit_inventory.rs @@ -0,0 +1,76 @@ +use { + crate::{ + instructions::shared::{err, error}, + state::Market, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct DepositInventory { + // `has_one(operator)` on the market is the whole access control: only the + // firm's key can stock the market. The vaults are bound to the market by + // `has_one` too, matching the addresses stored at creation. + #[account(mut)] + pub operator: Signer, + #[account( + address = Market::seeds(base_mint.address(), quote_mint.address()), + has_one(operator), + has_one(base_vault), + has_one(quote_vault), + )] + pub market: Account, + pub base_mint: Account, + pub quote_mint: Account, + #[account(mut)] + pub base_vault: Account, + #[account(mut)] + pub quote_vault: Account, + #[account(mut)] + pub operator_base: Account, + #[account(mut)] + pub operator_quote: Account, + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_deposit_inventory( + accounts: &mut DepositInventory, + base_amount: u64, + quote_amount: u64, +) -> Result<(), ProgramError> { + if base_amount == 0 && quote_amount == 0 { + return Err(err(error::ZERO_AMOUNT)); + } + + if base_amount > 0 { + accounts + .token_program + .transfer_checked( + &accounts.operator_base, + &accounts.base_mint, + &accounts.base_vault, + &accounts.operator, + base_amount, + accounts.base_mint.decimals(), + ) + .invoke()?; + } + + if quote_amount > 0 { + accounts + .token_program + .transfer_checked( + &accounts.operator_quote, + &accounts.quote_mint, + &accounts.quote_vault, + &accounts.operator, + quote_amount, + accounts.quote_mint.decimals(), + ) + .invoke()?; + } + + Ok(()) +} diff --git a/finance/prop-amm/quasar/src/instructions/initialize_market.rs b/finance/prop-amm/quasar/src/instructions/initialize_market.rs new file mode 100644 index 00000000..e8ce8e28 --- /dev/null +++ b/finance/prop-amm/quasar/src/instructions/initialize_market.rs @@ -0,0 +1,96 @@ +use { + crate::{ + constants::BASIS_POINTS_DENOMINATOR, + instructions::shared::{err, error}, + state::{Market, MarketInner}, + BaseVaultPda, MarketAuthorityPda, QuoteVaultPda, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct InitializeMarket { + #[account(mut)] + pub operator: Signer, + // One market per pair: the deployment IS the firm. A real prop AMM is a + // closed program deployed by the market-making firm itself. + #[account( + mut, + init, + payer = operator, + address = Market::seeds(base_mint.address(), quote_mint.address()), + )] + pub market: Account, + pub base_mint: Account, + pub quote_mint: Account, + /// CHECK: stored on the market; every read validates layout, scale, + /// freshness, and confidence. + pub oracle_feed: UncheckedAccount, + /// Authority PDA over both vaults. Holds no data; only signs. + #[account(address = MarketAuthorityPda::seeds(market.address()))] + pub market_authority: UncheckedAccount, + #[account( + mut, + init(idempotent), + payer = operator, + address = BaseVaultPda::seeds(market.address()), + token(mint = base_mint, authority = market_authority, token_program = token_program), + )] + pub base_vault: Account, + #[account( + mut, + init(idempotent), + payer = operator, + address = QuoteVaultPda::seeds(market.address()), + token(mint = quote_mint, authority = market_authority, token_program = token_program), + )] + pub quote_vault: Account, + pub token_program: Program, + pub system_program: Program, + pub rent: Sysvar, +} + +#[inline(always)] +pub fn handle_initialize_market( + accounts: &mut InitializeMarket, + oracle_scale: u32, + spread_bps: u16, + max_confidence_bps: u16, + bumps: &InitializeMarketBumps, +) -> Result<(), ProgramError> { + let denominator = BASIS_POINTS_DENOMINATOR as u16; + // A market quoting the same token against itself prices nothing. + if accounts.base_mint.address() == accounts.quote_mint.address() { + return Err(err(error::INVALID_PARAMETER)); + } + // Zero spread quotes the oracle price for free while paying adverse + // selection on every fill; at or above 100% the bid stops meaning anything. + if spread_bps == 0 || spread_bps >= denominator { + return Err(err(error::INVALID_PARAMETER)); + } + // Zero would reject every real feed; above 100% is meaningless. + if max_confidence_bps == 0 || max_confidence_bps >= denominator { + return Err(err(error::INVALID_PARAMETER)); + } + + let base_decimals = accounts.base_mint.decimals(); + let quote_decimals = accounts.quote_mint.decimals(); + accounts.market.set_inner(MarketInner { + operator: *accounts.operator.address(), + base_mint: *accounts.base_mint.address(), + quote_mint: *accounts.quote_mint.address(), + oracle_feed: *accounts.oracle_feed.address(), + base_vault: *accounts.base_vault.address(), + quote_vault: *accounts.quote_vault.address(), + oracle_scale, + base_decimals, + quote_decimals, + spread_bps, + max_confidence_bps, + paused: 0, + bump: bumps.market, + authority_bump: bumps.market_authority, + }); + Ok(()) +} diff --git a/finance/prop-amm/quasar/src/instructions/mod.rs b/finance/prop-amm/quasar/src/instructions/mod.rs new file mode 100644 index 00000000..a254dd43 --- /dev/null +++ b/finance/prop-amm/quasar/src/instructions/mod.rs @@ -0,0 +1,12 @@ +pub mod deposit_inventory; +pub mod initialize_market; +pub mod set_quote; +pub mod shared; +pub mod swap; +pub mod withdraw_inventory; + +pub use deposit_inventory::*; +pub use initialize_market::*; +pub use set_quote::*; +pub use swap::*; +pub use withdraw_inventory::*; diff --git a/finance/prop-amm/quasar/src/instructions/set_quote.rs b/finance/prop-amm/quasar/src/instructions/set_quote.rs new file mode 100644 index 00000000..a2b05b90 --- /dev/null +++ b/finance/prop-amm/quasar/src/instructions/set_quote.rs @@ -0,0 +1,48 @@ +use { + crate::{ + constants::BASIS_POINTS_DENOMINATOR, + instructions::shared::{err, error}, + state::Market, + }, + quasar_lang::prelude::*, +}; + +#[derive(Accounts)] +pub struct SetQuote { + pub operator: Signer, + #[account( + mut, + address = Market::seeds(base_mint.address(), quote_mint.address()), + has_one(operator), + )] + pub market: Account, + /// CHECK: seed input for the market PDA. + pub base_mint: UncheckedAccount, + /// CHECK: seed input for the market PDA. + pub quote_mint: UncheckedAccount, +} + +/// Re-quote the market: change the spread, pull the quotes (`paused = 1`), or +/// restore them. For a market-making firm this is the most-used instruction in +/// the program: when volatility rises, the rational moves are to widen the +/// spread or stop quoting entirely. +#[inline(always)] +pub fn handle_set_quote( + accounts: &mut SetQuote, + spread_bps: u16, + paused: u8, +) -> Result<(), ProgramError> { + // Same bounds as at creation: a quote must charge something and the bid + // must stay positive. + if spread_bps == 0 || spread_bps >= BASIS_POINTS_DENOMINATOR as u16 { + return Err(err(error::INVALID_PARAMETER)); + } + if paused > 1 { + return Err(err(error::INVALID_PARAMETER)); + } + + accounts.market.spread_bps.set(spread_bps); + // Single-byte fields are plain in the zero-copy view, so no setter. + accounts.market.paused = paused; + Ok(()) +} diff --git a/finance/prop-amm/quasar/src/instructions/shared.rs b/finance/prop-amm/quasar/src/instructions/shared.rs new file mode 100644 index 00000000..4ebe82a4 --- /dev/null +++ b/finance/prop-amm/quasar/src/instructions/shared.rs @@ -0,0 +1,269 @@ +//! The quote math and the oracle decode, ported verbatim from the Anchor +//! sibling (`prop_amm::quote_math` and `prop_amm::state::oracle`). All +//! integer, all `checked_*`, multiply-before-divide, every rounding direction +//! favoring the market. Errors are `ProgramError::Custom(code)`; the codes are +//! listed here. + +use quasar_lang::prelude::*; + +use crate::constants::{BASIS_POINTS_DENOMINATOR, MAX_PRICE_STALENESS_SLOTS}; + +pub mod error { + pub const ZERO_AMOUNT: u32 = 0; + pub const INVALID_PARAMETER: u32 = 1; + pub const STALE_PRICE: u32 = 2; + pub const NON_POSITIVE_PRICE: u32 = 3; + pub const ORACLE_SCALE_MISMATCH: u32 = 4; + pub const ORACLE_DATA_TOO_SHORT: u32 = 5; + pub const ORACLE_CONFIDENCE_TOO_WIDE: u32 = 6; + pub const SLIPPAGE_EXCEEDED: u32 = 7; + pub const INSUFFICIENT_INVENTORY: u32 = 8; + pub const MARKET_PAUSED: u32 = 9; + pub const AMOUNT_ROUNDS_TO_ZERO: u32 = 10; + pub const INVARIANT_VIOLATED: u32 = 11; + pub const INVALID_DIRECTION: u32 = 12; +} + +#[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, scale, and the confidence band; the feed account's owning +// program is NOT checked — the operator picks the oracle, and a bad choice +// loses the operator's money, not the traders'. +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. +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 market'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()) +} + +const BASIS_POINTS: u128 = BASIS_POINTS_DENOMINATOR as u128; + +/// The price a buyer of the base token pays: oracle plus the spread, rounded +/// UP so the rounding penny goes to the market, not the buyer. +pub fn ask_price(oracle_price: u64, spread_bps: u16) -> Result { + let numerator = (oracle_price as u128) + .checked_mul( + BASIS_POINTS + .checked_add(spread_bps as u128) + .ok_or_else(overflow)?, + ) + .ok_or_else(overflow)?; + Ok(numerator.div_ceil(BASIS_POINTS)) +} + +/// The price a seller of the base token receives: oracle minus the spread, +/// rounded DOWN — the same coin, the other face. +pub fn bid_price(oracle_price: u64, spread_bps: u16) -> Result { + let numerator = (oracle_price as u128) + .checked_mul( + BASIS_POINTS + .checked_sub(spread_bps as u128) + .ok_or_else(overflow)?, + ) + .ok_or_else(overflow)?; + Ok(numerator / BASIS_POINTS) +} + +/// Base tokens out for `quote_in` at the ask, floored. Multiply everything +/// before the one division so the floor happens exactly once, at the end. +pub fn base_out_for_quote_in( + quote_in: u64, + ask: u128, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Result { + let numerator = (quote_in as u128) + .checked_mul(10u128.checked_pow(oracle_scale).ok_or_else(overflow)?) + .ok_or_else(overflow)? + .checked_mul( + 10u128 + .checked_pow(base_decimals as u32) + .ok_or_else(overflow)?, + ) + .ok_or_else(overflow)?; + let denominator = ask + .checked_mul( + 10u128 + .checked_pow(quote_decimals as u32) + .ok_or_else(overflow)?, + ) + .ok_or_else(overflow)?; + if denominator == 0 { + return Err(overflow()); + } + u64::try_from(numerator / denominator).map_err(|_| overflow()) +} + +/// Quote tokens out for `base_in` at the bid, floored. +pub fn quote_out_for_base_in( + base_in: u64, + bid: u128, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Result { + let numerator = (base_in as u128) + .checked_mul(bid) + .ok_or_else(overflow)? + .checked_mul( + 10u128 + .checked_pow(quote_decimals as u32) + .ok_or_else(overflow)?, + ) + .ok_or_else(overflow)?; + let denominator = 10u128 + .checked_pow(oracle_scale) + .ok_or_else(overflow)? + .checked_mul( + 10u128 + .checked_pow(base_decimals as u32) + .ok_or_else(overflow)?, + ) + .ok_or_else(overflow)?; + if denominator == 0 { + return Err(overflow()); + } + u64::try_from(numerator / denominator).map_err(|_| overflow()) +} + +/// Both sides of a swap valued in the same fixed-point unit, cross-multiplied +/// so no division — and therefore no second rounding — is involved. +fn values_at_oracle( + base_amount: u64, + quote_amount: u64, + oracle_price: u64, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Result<(u128, u128), ProgramError> { + let base_value = (base_amount as u128) + .checked_mul(oracle_price as u128) + .ok_or_else(overflow)? + .checked_mul( + 10u128 + .checked_pow(quote_decimals as u32) + .ok_or_else(overflow)?, + ) + .ok_or_else(overflow)?; + let quote_value = (quote_amount as u128) + .checked_mul(10u128.checked_pow(oracle_scale).ok_or_else(overflow)?) + .ok_or_else(overflow)? + .checked_mul( + 10u128 + .checked_pow(base_decimals as u32) + .ok_or_else(overflow)?, + ) + .ok_or_else(overflow)?; + Ok((base_value, quote_value)) +} + +/// Post-math invariant for a buy: the base tokens handed out must be worth no +/// more, at the raw oracle price (no spread), than the quote tokens taken in. +pub fn buy_respects_oracle_value( + quote_in: u64, + base_out: u64, + oracle_price: u64, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Result { + let (base_value, quote_value) = values_at_oracle( + base_out, + quote_in, + oracle_price, + oracle_scale, + base_decimals, + quote_decimals, + )?; + Ok(base_value <= quote_value) +} + +/// The same invariant for a sell: the quote tokens handed out must be worth no +/// more, at the raw oracle price, than the base tokens taken in. +pub fn sell_respects_oracle_value( + base_in: u64, + quote_out: u64, + oracle_price: u64, + oracle_scale: u32, + base_decimals: u8, + quote_decimals: u8, +) -> Result { + let (base_value, quote_value) = values_at_oracle( + base_in, + quote_out, + oracle_price, + oracle_scale, + base_decimals, + quote_decimals, + )?; + Ok(quote_value <= base_value) +} diff --git a/finance/prop-amm/quasar/src/instructions/swap.rs b/finance/prop-amm/quasar/src/instructions/swap.rs new file mode 100644 index 00000000..91b2711c --- /dev/null +++ b/finance/prop-amm/quasar/src/instructions/swap.rs @@ -0,0 +1,201 @@ +use { + crate::{ + constants::{DIRECTION_BUY_BASE, DIRECTION_SELL_BASE}, + instructions::shared::{self, err, error}, + state::Market, + MarketAuthorityPda, + }, + quasar_lang::{prelude::*, sysvars::clock::Clock}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct Swap { + #[account(mut)] + pub trader: Signer, + #[account( + address = Market::seeds(base_mint.address(), quote_mint.address()), + has_one(oracle_feed), + has_one(base_vault), + has_one(quote_vault), + )] + pub market: Account, + /// Authority PDA over both vaults; holds no data, only signs. + #[account(address = MarketAuthorityPda::seeds(market.address()))] + pub market_authority: UncheckedAccount, + /// CHECK: bound to the market via `has_one(oracle_feed)`. + pub oracle_feed: UncheckedAccount, + pub base_mint: Account, + pub quote_mint: Account, + #[account(mut)] + pub base_vault: Account, + #[account(mut)] + pub quote_vault: Account, + /// The trader's base-token account. Unlike the Anchor sibling (which uses + /// `init_if_needed`), it must already exist. + #[account(mut)] + pub trader_base: Account, + #[account(mut)] + pub trader_quote: Account, + pub token_program: Program, + pub clock: Sysvar, +} + +/// Fill a swap against the operator's quote. The price does not depend on the +/// vault balances, the size of the trade, or who traded before you: it is the +/// oracle price plus or minus the spread, full stop. The balances only decide +/// whether the market *can* fill you. +#[inline(always)] +pub fn handle_swap( + accounts: &mut Swap, + direction: u8, + amount_in: u64, + minimum_amount_out: u64, +) -> Result<(), ProgramError> { + let market = &accounts.market; + // Single-byte fields are plain in the zero-copy view; wider integers go + // through `.get()`. + if market.paused != 0 { + return Err(err(error::MARKET_PAUSED)); + } + if amount_in == 0 { + return Err(err(error::ZERO_AMOUNT)); + } + + let oracle_scale = market.oracle_scale.get(); + let base_decimals = market.base_decimals; + let quote_decimals = market.quote_decimals; + let spread_bps = market.spread_bps.get(); + + // Freshness, scale, and confidence are all enforced inside the read. + let slot = accounts.clock.slot.get(); + let oracle_price = { + let view = accounts.oracle_feed.to_account_view(); + let data = view + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; + shared::read_oracle_price(&data, oracle_scale, slot, market.max_confidence_bps.get())? + }; + + let (amount_out, respects_oracle_value) = match direction { + DIRECTION_BUY_BASE => { + let ask = shared::ask_price(oracle_price, spread_bps)?; + let base_out = shared::base_out_for_quote_in( + amount_in, + ask, + oracle_scale, + base_decimals, + quote_decimals, + )?; + let respects = shared::buy_respects_oracle_value( + amount_in, + base_out, + oracle_price, + oracle_scale, + base_decimals, + quote_decimals, + )?; + (base_out, respects) + } + DIRECTION_SELL_BASE => { + let bid = shared::bid_price(oracle_price, spread_bps)?; + let quote_out = shared::quote_out_for_base_in( + amount_in, + bid, + oracle_scale, + base_decimals, + quote_decimals, + )?; + let respects = shared::sell_respects_oracle_value( + amount_in, + quote_out, + oracle_price, + oracle_scale, + base_decimals, + quote_decimals, + )?; + (quote_out, respects) + } + _ => return Err(err(error::INVALID_DIRECTION)), + }; + + if amount_out == 0 { + return Err(err(error::AMOUNT_ROUNDS_TO_ZERO)); + } + if amount_out < minimum_amount_out { + return Err(err(error::SLIPPAGE_EXCEEDED)); + } + + // Assert after the math, not just before: the market must never hand out + // more value than it took in, measured at the raw oracle price. + if !respects_oracle_value { + return Err(err(error::INVARIANT_VIOLATED)); + } + + let vault_out_balance = match direction { + DIRECTION_BUY_BASE => accounts.base_vault.amount(), + _ => accounts.quote_vault.amount(), + }; + if amount_out > vault_out_balance { + return Err(err(error::INSUFFICIENT_INVENTORY)); + } + + let bump = [accounts.market.authority_bump]; + let market_address = *accounts.market.address(); + let seeds: &[Seed] = &[ + Seed::from(b"authority".as_ref()), + Seed::from(market_address.as_ref()), + Seed::from(&bump as &[u8]), + ]; + + // The trader pays in, then the vault pays out, atomically or not at all. + if direction == DIRECTION_BUY_BASE { + accounts + .token_program + .transfer_checked( + &accounts.trader_quote, + &accounts.quote_mint, + &accounts.quote_vault, + &accounts.trader, + amount_in, + accounts.quote_mint.decimals(), + ) + .invoke()?; + accounts + .token_program + .transfer_checked( + &accounts.base_vault, + &accounts.base_mint, + &accounts.trader_base, + &accounts.market_authority, + amount_out, + accounts.base_mint.decimals(), + ) + .invoke_signed(seeds)?; + } else { + accounts + .token_program + .transfer_checked( + &accounts.trader_base, + &accounts.base_mint, + &accounts.base_vault, + &accounts.trader, + amount_in, + accounts.base_mint.decimals(), + ) + .invoke()?; + accounts + .token_program + .transfer_checked( + &accounts.quote_vault, + &accounts.quote_mint, + &accounts.trader_quote, + &accounts.market_authority, + amount_out, + accounts.quote_mint.decimals(), + ) + .invoke_signed(seeds)?; + } + + Ok(()) +} diff --git a/finance/prop-amm/quasar/src/instructions/withdraw_inventory.rs b/finance/prop-amm/quasar/src/instructions/withdraw_inventory.rs new file mode 100644 index 00000000..8a1b112b --- /dev/null +++ b/finance/prop-amm/quasar/src/instructions/withdraw_inventory.rs @@ -0,0 +1,95 @@ +use { + crate::{ + instructions::shared::{err, error}, + state::Market, + MarketAuthorityPda, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct WithdrawInventory { + #[account(mut)] + pub operator: Signer, + #[account( + address = Market::seeds(base_mint.address(), quote_mint.address()), + has_one(operator), + has_one(base_vault), + has_one(quote_vault), + )] + pub market: Account, + /// Authority PDA over both vaults; holds no data, only signs. + #[account(address = MarketAuthorityPda::seeds(market.address()))] + pub market_authority: UncheckedAccount, + pub base_mint: Account, + pub quote_mint: Account, + #[account(mut)] + pub base_vault: Account, + #[account(mut)] + pub quote_vault: Account, + #[account(mut)] + pub operator_base: Account, + #[account(mut)] + pub operator_quote: Account, + pub token_program: Program, +} + +/// The operator takes inventory back out — up to every token in both vaults, +/// at any time. There are no liquidity-provider shares because there are no +/// liquidity providers: the capital is the firm's own, so its exit needs no +/// waterfall, no share burn, and no pro-rata math. +#[inline(always)] +pub fn handle_withdraw_inventory( + accounts: &mut WithdrawInventory, + base_amount: u64, + quote_amount: u64, +) -> Result<(), ProgramError> { + if base_amount == 0 && quote_amount == 0 { + return Err(err(error::ZERO_AMOUNT)); + } + if base_amount > accounts.base_vault.amount() { + return Err(err(error::INSUFFICIENT_INVENTORY)); + } + if quote_amount > accounts.quote_vault.amount() { + return Err(err(error::INSUFFICIENT_INVENTORY)); + } + + let bump = [accounts.market.authority_bump]; + let market_address = *accounts.market.address(); + let seeds: &[Seed] = &[ + Seed::from(b"authority".as_ref()), + Seed::from(market_address.as_ref()), + Seed::from(&bump as &[u8]), + ]; + + if base_amount > 0 { + accounts + .token_program + .transfer_checked( + &accounts.base_vault, + &accounts.base_mint, + &accounts.operator_base, + &accounts.market_authority, + base_amount, + accounts.base_mint.decimals(), + ) + .invoke_signed(seeds)?; + } + + if quote_amount > 0 { + accounts + .token_program + .transfer_checked( + &accounts.quote_vault, + &accounts.quote_mint, + &accounts.operator_quote, + &accounts.market_authority, + quote_amount, + accounts.quote_mint.decimals(), + ) + .invoke_signed(seeds)?; + } + + Ok(()) +} diff --git a/finance/prop-amm/quasar/src/lib.rs b/finance/prop-amm/quasar/src/lib.rs new file mode 100644 index 00000000..795c5eec --- /dev/null +++ b/finance/prop-amm/quasar/src/lib.rs @@ -0,0 +1,91 @@ +#![cfg_attr(not(test), no_std)] + +//! Quasar port of the prop-amm example. The design, math, and behaviour match +//! the Anchor sibling at `finance/prop-amm/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!("FPTx81bSwghfrwzaQpgmKPw1TnajK66wif1cQyev4GdD"); + +/// Authority PDA at seeds = [b"authority", market]. Signs vault CPIs. +#[derive(Seeds)] +#[seeds(b"authority", market: Address)] +pub struct MarketAuthorityPda; + +/// Base-token vault PDA at seeds = [b"base_vault", market]. +#[derive(Seeds)] +#[seeds(b"base_vault", market: Address)] +pub struct BaseVaultPda; + +/// Quote-token vault PDA at seeds = [b"quote_vault", market]. +#[derive(Seeds)] +#[seeds(b"quote_vault", market: Address)] +pub struct QuoteVaultPda; + +#[program] +mod quasar_prop_amm { + use super::*; + + #[instruction(discriminator = 0)] + pub fn initialize_market( + ctx: Ctx, + oracle_scale: u32, + spread_bps: u16, + max_confidence_bps: u16, + ) -> Result<(), ProgramError> { + instructions::handle_initialize_market( + &mut ctx.accounts, + oracle_scale, + spread_bps, + max_confidence_bps, + &ctx.bumps, + ) + } + + #[instruction(discriminator = 1)] + pub fn deposit_inventory( + ctx: Ctx, + base_amount: u64, + quote_amount: u64, + ) -> Result<(), ProgramError> { + instructions::handle_deposit_inventory(&mut ctx.accounts, base_amount, quote_amount) + } + + #[instruction(discriminator = 2)] + pub fn withdraw_inventory( + ctx: Ctx, + base_amount: u64, + quote_amount: u64, + ) -> Result<(), ProgramError> { + instructions::handle_withdraw_inventory(&mut ctx.accounts, base_amount, quote_amount) + } + + #[instruction(discriminator = 3)] + pub fn set_quote( + ctx: Ctx, + spread_bps: u16, + paused: u8, + ) -> Result<(), ProgramError> { + instructions::handle_set_quote(&mut ctx.accounts, spread_bps, paused) + } + + #[instruction(discriminator = 4)] + pub fn swap( + ctx: Ctx, + direction: u8, + amount_in: u64, + minimum_amount_out: u64, + ) -> Result<(), ProgramError> { + instructions::handle_swap(&mut ctx.accounts, direction, amount_in, minimum_amount_out) + } +} diff --git a/finance/prop-amm/quasar/src/state.rs b/finance/prop-amm/quasar/src/state.rs new file mode 100644 index 00000000..617fabcf --- /dev/null +++ b/finance/prop-amm/quasar/src/state.rs @@ -0,0 +1,33 @@ +use quasar_lang::prelude::*; + +/// One quoted market. Mirrors the Anchor `Market` field-for-field; see the +/// Anchor sibling's README for what each field means. `paused` is a `u8` +/// (0 = quoting, 1 = pulled) because the account layout is zero-copy. +/// +/// Note what this account does NOT hold, compared to a curve AMM's pool: no +/// liquidity-provider mint, no fee ledger, no reserves that pricing depends +/// on. The operator is the only capital in the market. +#[account(discriminator = 100, set_inner)] +#[seeds(b"market", base_mint: Address, quote_mint: Address)] +pub struct Market { + pub operator: Address, + pub base_mint: Address, + pub quote_mint: Address, + pub oracle_feed: Address, + pub base_vault: Address, + pub quote_vault: Address, + /// Decimal places the oracle price is quoted in, pinned at creation. + pub oracle_scale: u32, + pub base_decimals: u8, + pub quote_decimals: u8, + /// Half-spread in basis points: ask = oracle + spread, bid = oracle - spread. + /// The spread is the operator's entire revenue — there is no separate fee. + pub spread_bps: u16, + /// Maximum oracle confidence band, in basis points of the price, the + /// market will quote against. + pub max_confidence_bps: u16, + /// 1 while the operator has pulled its quotes; swaps are rejected. + pub paused: u8, + pub bump: u8, + pub authority_bump: u8, +} diff --git a/finance/prop-amm/quasar/src/tests.rs b/finance/prop-amm/quasar/src/tests.rs new file mode 100644 index 00000000..0b347e98 --- /dev/null +++ b/finance/prop-amm/quasar/src/tests.rs @@ -0,0 +1,585 @@ +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, +}; + +// Both tokens have 6 decimals: base is NVDAx (tokenized NVIDIA stock), quote +// is USDC. +const ONE_TOKEN: u64 = 1_000_000; +// The oracle quotes prices with 8 decimals, so $165 is 165 * 10^8. +const ORACLE_SCALE: u32 = 8; +const SPREAD_BPS: u16 = 10; +const MAX_CONFIDENCE_BPS: u16 = 100; + +const DIRECTION_BUY_BASE: u8 = 0; +const DIRECTION_SELL_BASE: u8 = 1; + +// A fixed current slot well above the staleness bound, so tests can write +// feed accounts that are fresh (slot = SLOT) or stale (slot older than the +// 150-slot bound) without warping the SVM clock. +const SLOT: u64 = 1_000; + +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, + base_mint: Pubkey, + quote_mint: Pubkey, + feed: Pubkey, + operator: Pubkey, + market: Pubkey, + market_authority: Pubkey, + base_vault: Pubkey, + quote_vault: Pubkey, +} + +/// Build an SVM with the program, both mints, an oracle feed at $165, an +/// initialized market at a 10 bps spread, and 1,000 NVDAx + 200,000 USDC of +/// operator inventory deposited. +fn setup() -> Env { + let mut env = try_setup(SPREAD_BPS).expect("market initialization should succeed"); + assert!(env.deposit_inventory_as_operator(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN)); + env +} + +/// Like `setup`, but without the inventory deposit and with the spread exposed +/// so tests can probe the parameter validation. +fn try_setup(spread_bps: u16) -> Result { + let elf = fs::read("target/deploy/quasar_prop_amm.so").unwrap(); + let base_mint = Pubkey::new_unique(); + let quote_mint = Pubkey::new_unique(); + let feed = Pubkey::new_unique(); + let operator = Pubkey::new_unique(); + let market = pda(&[b"market", base_mint.as_ref(), quote_mint.as_ref()]); + let market_authority = pda(&[b"authority", market.as_ref()]); + let base_vault = pda(&[b"base_vault", market.as_ref()]); + let quote_vault = pda(&[b"quote_vault", market.as_ref()]); + + let mut svm = QuasarSvm::new() + .with_program(&crate::ID, &elf) + .with_token_program() + .with_slot(SLOT) + .with_account(mint_account(&base_mint)) + .with_account(mint_account("e_mint)) + .with_account(feed_account(&feed, dollars(165), ORACLE_SCALE, SLOT, 0)) + .with_account(create_keyed_system_account(&operator, 100_000_000_000)); + + // The operator's own inventory accounts, funded. + svm.set_account(create_keyed_associated_token_account( + &operator, + &base_mint, + 10_000 * ONE_TOKEN, + )); + svm.set_account(create_keyed_associated_token_account( + &operator, + "e_mint, + 10_000_000 * ONE_TOKEN, + )); + + // initialize_market + let mut data = vec![0u8]; + data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); + data.extend_from_slice(&spread_bps.to_le_bytes()); + data.extend_from_slice(&MAX_CONFIDENCE_BPS.to_le_bytes()); + let metas = vec![ + AccountMeta::new(operator, true), + AccountMeta::new(market, false), + AccountMeta::new_readonly(base_mint, false), + AccountMeta::new_readonly(quote_mint, false), + AccountMeta::new_readonly(feed, false), + AccountMeta::new_readonly(market_authority, false), + AccountMeta::new(base_vault, false), + AccountMeta::new(quote_vault, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(system_program(), false), + AccountMeta::new_readonly(rent_sysvar(), false), + ]; + let provided = vec![ + svm.get_account(&operator).unwrap(), + empty(&market), + svm.get_account(&base_mint).unwrap(), + svm.get_account("e_mint).unwrap(), + empty(&base_vault), + empty("e_vault), + ]; + let result = svm.process_instruction( + &Instruction { + program_id: program_id(), + accounts: metas, + data, + }, + &provided, + ); + if !result.is_ok() { + return Err(()); + } + + Ok(Env { + svm, + base_mint, + quote_mint, + feed, + operator, + market, + market_authority, + base_vault, + quote_vault, + }) +} + +impl Env { + /// Create a wallet with funded base and quote token accounts. + fn funded_wallet(&mut self, base: u64, quote: u64) -> Pubkey { + let wallet = Pubkey::new_unique(); + self.svm + .set_account(create_keyed_system_account(&wallet, 100_000_000_000)); + self.svm.set_account(create_keyed_associated_token_account( + &wallet, + &self.base_mint, + base, + )); + self.svm.set_account(create_keyed_associated_token_account( + &wallet, + &self.quote_mint, + quote, + )); + wallet + } + + 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, + )); + } + + /// Write the feed with an update slot older than the 150-slot staleness + /// bound (the SVM clock sits at `SLOT`). + fn make_price_stale(&mut self) { + self.svm.set_account(feed_account( + &self.feed, + dollars(165), + ORACLE_SCALE, + SLOT - 151, + 0, + )); + } + + fn move_inventory(&mut self, signer: &Pubkey, deposit: bool, base: u64, quote: u64) -> bool { + let signer_base = ata(signer, &self.base_mint); + let signer_quote = ata(signer, &self.quote_mint); + let mut data = vec![if deposit { 1u8 } else { 2u8 }]; + data.extend_from_slice(&base.to_le_bytes()); + data.extend_from_slice("e.to_le_bytes()); + let mut metas = vec![AccountMeta::new(*signer, true)]; + metas.push(AccountMeta::new_readonly(self.market, false)); + if !deposit { + metas.push(AccountMeta::new_readonly(self.market_authority, false)); + } + metas.extend([ + AccountMeta::new_readonly(self.base_mint, false), + AccountMeta::new_readonly(self.quote_mint, false), + AccountMeta::new(self.base_vault, false), + AccountMeta::new(self.quote_vault, false), + AccountMeta::new(signer_base, false), + AccountMeta::new(signer_quote, false), + AccountMeta::new_readonly(token_program(), false), + ]); + self.run(metas, data, &[*signer, signer_base, signer_quote]) + } + + fn deposit_inventory_as_operator(&mut self, base: u64, quote: u64) -> bool { + let operator = self.operator; + self.move_inventory(&operator, true, base, quote) + } + + fn withdraw_inventory_as_operator(&mut self, base: u64, quote: u64) -> bool { + let operator = self.operator; + self.move_inventory(&operator, false, base, quote) + } + + fn set_quote(&mut self, signer: &Pubkey, spread_bps: u16, paused: u8) -> bool { + let mut data = vec![3u8]; + data.extend_from_slice(&spread_bps.to_le_bytes()); + data.push(paused); + let metas = vec![ + AccountMeta::new_readonly(*signer, true), + AccountMeta::new(self.market, false), + AccountMeta::new_readonly(self.base_mint, false), + AccountMeta::new_readonly(self.quote_mint, false), + ]; + self.run(metas, data, &[*signer]) + } + + fn swap( + &mut self, + trader: &Pubkey, + direction: u8, + amount_in: u64, + minimum_amount_out: u64, + ) -> bool { + let trader_base = ata(trader, &self.base_mint); + let trader_quote = ata(trader, &self.quote_mint); + let mut data = vec![4u8, direction]; + data.extend_from_slice(&amount_in.to_le_bytes()); + data.extend_from_slice(&minimum_amount_out.to_le_bytes()); + let metas = vec![ + AccountMeta::new(*trader, true), + AccountMeta::new_readonly(self.market, false), + AccountMeta::new_readonly(self.market_authority, false), + AccountMeta::new_readonly(self.feed, false), + AccountMeta::new_readonly(self.base_mint, false), + AccountMeta::new_readonly(self.quote_mint, false), + AccountMeta::new(self.base_vault, false), + AccountMeta::new(self.quote_vault, false), + AccountMeta::new(trader_base, false), + AccountMeta::new(trader_quote, false), + AccountMeta::new_readonly(token_program(), false), + AccountMeta::new_readonly(clock_sysvar(), false), + ]; + self.run(metas, data, &[*trader, trader_base, trader_quote]) + } + + 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_market() { + let env = setup(); + // The market and both vaults were created, and the inventory landed. + assert!(env.svm.get_account(&env.market).is_some()); + assert_eq!(token_amount(&env.svm, &env.base_vault), 1_000 * ONE_TOKEN); + assert_eq!( + token_amount(&env.svm, &env.quote_vault), + 200_000 * ONE_TOKEN + ); +} + +/// Alice buys 10 NVDAx. At $165 with a 10 bps spread the ask is $165.165, so +/// 10 NVDAx costs exactly 1,651.65 USDC. +#[test] +fn test_swap_buys_base_at_the_ask() { + let mut env = setup(); + let quote_in = 1_651_650_000; + let alice = env.funded_wallet(0, quote_in); + + assert!(env.swap(&alice, DIRECTION_BUY_BASE, quote_in, 10 * ONE_TOKEN)); + + assert_eq!( + token_amount(&env.svm, &ata(&alice, &env.base_mint)), + 10 * ONE_TOKEN + ); + assert_eq!(token_amount(&env.svm, &ata(&alice, &env.quote_mint)), 0); + // Conservation: the vaults moved by exactly the two legs of the fill. + assert_eq!(token_amount(&env.svm, &env.base_vault), 990 * ONE_TOKEN); + assert_eq!( + token_amount(&env.svm, &env.quote_vault), + 200_000 * ONE_TOKEN + quote_in + ); +} + +/// Bob sells 10 NVDAx. At $165 with a 10 bps spread the bid is $164.835, so +/// he receives exactly 1,648.35 USDC. +#[test] +fn test_swap_sells_base_at_the_bid() { + let mut env = setup(); + let bob = env.funded_wallet(10 * ONE_TOKEN, 0); + + assert!(env.swap(&bob, DIRECTION_SELL_BASE, 10 * ONE_TOKEN, 1_648_350_000)); + + assert_eq!(token_amount(&env.svm, &ata(&bob, &env.base_mint)), 0); + assert_eq!( + token_amount(&env.svm, &ata(&bob, &env.quote_mint)), + 1_648_350_000 + ); +} + +/// A buy immediately followed by a sell of the same 10 NVDAx costs exactly +/// the round-trip spread: 3.30 USDC, all of which stays in the inventory. +#[test] +fn test_round_trip_costs_exactly_the_spread() { + let mut env = setup(); + let quote_in = 1_651_650_000; + let carol = env.funded_wallet(0, quote_in); + + assert!(env.swap(&carol, DIRECTION_BUY_BASE, quote_in, 0)); + assert!(env.swap(&carol, DIRECTION_SELL_BASE, 10 * ONE_TOKEN, 0)); + + assert_eq!(token_amount(&env.svm, &ata(&carol, &env.base_mint)), 0); + assert_eq!( + token_amount(&env.svm, &ata(&carol, &env.quote_mint)), + quote_in - 3_300_000 + ); + assert_eq!( + token_amount(&env.svm, &env.quote_vault), + 200_000 * ONE_TOKEN + 3_300_000 + ); +} + +/// When the oracle reprices, the quote follows instantly. At $170 the ask is +/// $170.17, so 10 NVDAx costs exactly 1,701.70 USDC. +#[test] +fn test_quote_follows_the_oracle() { + let mut env = setup(); + env.set_price(dollars(170)); + + let quote_in = 1_701_700_000; + let alice = env.funded_wallet(0, quote_in); + assert!(env.swap(&alice, DIRECTION_BUY_BASE, quote_in, 10 * ONE_TOKEN)); + assert_eq!( + token_amount(&env.svm, &ata(&alice, &env.base_mint)), + 10 * ONE_TOKEN + ); +} + +/// The operator re-quotes to a 50 bps spread; the next fill prices at +/// $165.825, so 10 NVDAx costs exactly 1,658.25 USDC. +#[test] +fn test_set_quote_changes_the_spread() { + let mut env = setup(); + let operator = env.operator; + assert!(env.set_quote(&operator, 50, 0)); + + let quote_in = 1_658_250_000; + let alice = env.funded_wallet(0, quote_in); + assert!(env.swap(&alice, DIRECTION_BUY_BASE, quote_in, 10 * ONE_TOKEN)); + assert_eq!( + token_amount(&env.svm, &ata(&alice, &env.base_mint)), + 10 * ONE_TOKEN + ); +} + +/// The operator can withdraw every token in both vaults at any time — its +/// capital, its exit. Afterwards swaps fail rather than misprice. +#[test] +fn test_operator_can_withdraw_everything_and_swaps_then_fail() { + let mut env = setup(); + assert!(env.withdraw_inventory_as_operator(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN)); + + assert_eq!(token_amount(&env.svm, &env.base_vault), 0); + assert_eq!(token_amount(&env.svm, &env.quote_vault), 0); + + let alice = env.funded_wallet(0, 1_651_650_000); + assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 1_651_650_000, 0)); +} + +#[test] +fn test_withdraw_more_than_inventory_fails() { + let mut env = setup(); + assert!(!env.withdraw_inventory_as_operator(1_001 * ONE_TOKEN, 0)); +} + +#[test] +fn test_deposit_rejects_non_operator() { + let mut env = setup(); + let mallory = env.funded_wallet(ONE_TOKEN, ONE_TOKEN); + assert!(!env.move_inventory(&mallory, true, ONE_TOKEN, 0)); +} + +#[test] +fn test_withdraw_rejects_non_operator() { + let mut env = setup(); + let mallory = env.funded_wallet(0, 0); + assert!(!env.move_inventory(&mallory, false, ONE_TOKEN, 0)); +} + +#[test] +fn test_set_quote_rejects_non_operator() { + let mut env = setup(); + let mallory = env.funded_wallet(0, 0); + assert!(!env.set_quote(&mallory, 500, 1)); +} + +/// A fill below the caller's minimum is rejected, not filled worse. +#[test] +fn test_swap_rejects_slippage() { + let mut env = setup(); + let quote_in = 1_651_650_000; + let alice = env.funded_wallet(0, quote_in); + // The fill would be exactly 10 NVDAx; demand one minor unit more. + assert!(!env.swap(&alice, DIRECTION_BUY_BASE, quote_in, 10 * ONE_TOKEN + 1)); +} + +/// An oracle price older than the staleness bound cannot be traded against: +/// a lagging quote is a free option for arbitrageurs. +#[test] +fn test_swap_rejects_stale_price() { + let mut env = setup(); + env.make_price_stale(); + let alice = env.funded_wallet(0, 1_651_650_000); + assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 1_651_650_000, 0)); +} + +/// A price the oracle itself is unsure about is rejected: the confidence band +/// (about 1.2% here) exceeds the market's 1% limit. +#[test] +fn test_swap_rejects_wide_confidence() { + let mut env = setup(); + env.set_price_with_confidence(dollars(165), 200_000_000); + let alice = env.funded_wallet(0, 1_651_650_000); + assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 1_651_650_000, 0)); +} + +/// While the operator has pulled its quotes, nobody can swap; unpausing +/// restores the exact same quote. +#[test] +fn test_swap_rejects_when_paused() { + let mut env = setup(); + let operator = env.operator; + assert!(env.set_quote(&operator, SPREAD_BPS, 1)); + + let alice = env.funded_wallet(0, 1_651_650_000); + assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 1_651_650_000, 0)); + + assert!(env.set_quote(&operator, SPREAD_BPS, 0)); + assert!(env.swap(&alice, DIRECTION_BUY_BASE, 1_651_650_000, 10 * ONE_TOKEN)); +} + +#[test] +fn test_swap_rejects_zero_amount() { + let mut env = setup(); + let alice = env.funded_wallet(0, ONE_TOKEN); + assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 0, 0)); +} + +/// A buy bigger than the base inventory is rejected whole — a prop AMM never +/// partially fills, and never prices what it cannot deliver. +#[test] +fn test_swap_rejects_insufficient_inventory() { + let mut env = setup(); + // 1,100 NVDAx at $165.165 ≈ 181,681.50 USDC — affordable for the trader, + // but the vault only holds 1,000 NVDAx. + let quote_in = 181_681_500_000; + let whale = env.funded_wallet(0, quote_in); + assert!(!env.swap(&whale, DIRECTION_BUY_BASE, quote_in, 0)); +} + +#[test] +fn test_initialize_market_rejects_zero_spread() { + assert!(try_setup(0).is_err()); +} + +#[test] +fn test_initialize_market_rejects_full_spread() { + assert!(try_setup(10_000).is_err()); +} + +#[test] +fn test_set_quote_rejects_invalid_spread() { + let mut env = setup(); + let operator = env.operator; + assert!(!env.set_quote(&operator, 0, 0)); + assert!(!env.set_quote(&operator, 10_000, 0)); +}