diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 6951535..3faa301 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -256,12 +256,16 @@ pub const FEE_TIER_BPS_1: u128 = 1; pub const FEE_TIER_BPS_5: u128 = 5; pub const FEE_TIER_BPS_30: u128 = 30; pub const FEE_TIER_BPS_100: u128 = 100; +/// Fee tiers accepted by pool creation and all initialized-pool operations. +pub const SUPPORTED_FEE_TIERS: [u128; 4] = [ + FEE_TIER_BPS_1, + FEE_TIER_BPS_5, + FEE_TIER_BPS_30, + FEE_TIER_BPS_100, +]; pub fn is_supported_fee_tier(fees: u128) -> bool { - matches!( - fees, - FEE_TIER_BPS_1 | FEE_TIER_BPS_5 | FEE_TIER_BPS_30 | FEE_TIER_BPS_100 - ) + SUPPORTED_FEE_TIERS.contains(&fees) } pub fn assert_supported_fee_tier(fees: u128) { @@ -303,19 +307,54 @@ pub fn spot_price_q64_64(reserve_base: u128, reserve_quote: u128) -> u128 { /// `floor(a * b / c)` computed in U256 so the `a * b` product can't overflow u128. /// (Storage stays u128; only the intermediate widens.) /// -/// # Panics -/// Panics if `c` is zero, or if the result exceeds u128. +/// Returns `None` when `c` is zero or the quotient does not fit in `u128`. #[must_use] -pub fn mul_div_floor(a: u128, b: u128, c: u128) -> u128 { +pub fn checked_mul_div_floor(a: u128, b: u128, c: u128) -> Option { use alloy_primitives::U256; - assert!(c != 0, "mul_div_floor: divisor must be non-zero"); + + if c == 0 { + return None; + } + let product = U256::from(a) .checked_mul(U256::from(b)) .expect("u128 * u128 always fits in U256"); let result = product .checked_div(U256::from(c)) - .expect("mul_div_floor: divisor is non-zero after the assertion above"); - u128::try_from(result).expect("mul_div_floor result exceeds u128") + .expect("c is non-zero after the guard above"); + + u128::try_from(result).ok() +} + +/// `floor(a * b / c)` computed in U256 so the `a * b` product can't overflow u128. +/// (Storage stays u128; only the intermediate widens.) +/// +/// # Panics +/// Panics if `c` is zero, or if the result exceeds u128. +#[must_use] +pub fn mul_div_floor(a: u128, b: u128, c: u128) -> u128 { + assert!(c != 0, "mul_div_floor: divisor must be non-zero"); + checked_mul_div_floor(a, b, c).expect("mul_div_floor result exceeds u128") +} + +/// `ceil(a * b / c)` computed in U256 so the `a * b` product can't overflow u128. +/// (Storage stays u128; only the intermediate widens.) +/// +/// Returns `None` when `c` is zero or the quotient does not fit in `u128`. +#[must_use] +pub fn checked_mul_div_ceil(a: u128, b: u128, c: u128) -> Option { + use alloy_primitives::U256; + + if c == 0 { + return None; + } + + let product = U256::from(a) + .checked_mul(U256::from(b)) + .expect("u128 * u128 always fits in U256"); + let result = product.div_ceil(U256::from(c)); + + u128::try_from(result).ok() } /// `ceil(a * b / c)` computed in U256 so the `a * b` product can't overflow u128. @@ -325,13 +364,8 @@ pub fn mul_div_floor(a: u128, b: u128, c: u128) -> u128 { /// Panics if `c` is zero, or if the result exceeds u128. #[must_use] pub fn mul_div_ceil(a: u128, b: u128, c: u128) -> u128 { - use alloy_primitives::U256; assert!(c != 0, "mul_div_ceil: divisor must be non-zero"); - let product = U256::from(a) - .checked_mul(U256::from(b)) - .expect("u128 * u128 always fits in U256"); - let result = product.div_ceil(U256::from(c)); - u128::try_from(result).expect("mul_div_ceil result exceeds u128") + checked_mul_div_ceil(a, b, c).expect("mul_div_ceil result exceeds u128") } /// Adverse price impact of a swap in basis points: how far `amount_out` falls @@ -720,6 +754,13 @@ mod tests { assert_eq!(mul_div_floor(1, 1, 2), 0); } + #[test] + fn checked_mul_div_floor_reports_invalid_results() { + assert_eq!(checked_mul_div_floor(1, 1, 0), None); + assert_eq!(checked_mul_div_floor(u128::MAX, u128::MAX, 1), None); + assert_eq!(checked_mul_div_floor(7, 7, 3), Some(16)); + } + #[test] fn mul_div_floor_product_exceeds_u128() { // 2e30 * 2e30 = 4e60, far beyond u128; / 1e20 = 4e40, still beyond u128 -- but the @@ -816,6 +857,13 @@ mod tests { assert_eq!(mul_div_ceil(0, 12345, 7), 0); } + #[test] + fn checked_mul_div_ceil_reports_invalid_results() { + assert_eq!(checked_mul_div_ceil(1, 1, 0), None); + assert_eq!(checked_mul_div_ceil(u128::MAX, u128::MAX, 1), None); + assert_eq!(checked_mul_div_ceil(7, 7, 3), Some(17)); + } + #[test] fn mul_div_ceil_product_exceeds_u128() { // (2e30 * 2e30) / 2e30 = 2e30 exactly, fits in u128. diff --git a/programs/amm/src/add.rs b/programs/amm/src/add.rs index 80d03b3..445c039 100644 --- a/programs/amm/src/add.rs +++ b/programs/amm/src/add.rs @@ -1,9 +1,8 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, - compute_pool_pda_seed, mul_div_floor, read_vault_fungible_balances, spot_price_q64_64, - AmmConfig, PoolDefinition, + compute_config_pda, compute_liquidity_token_pda_seed, compute_pool_pda_seed, + read_vault_fungible_balances, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ @@ -12,6 +11,8 @@ use nssa_core::{ }; use twap_oracle_core::compute_current_tick_account_pda; +use crate::quote; + #[expect( clippy::too_many_arguments, reason = "instruction surface passes explicit pool, vault, and user accounts" @@ -47,7 +48,6 @@ pub fn add_liquidity( // 1. Fetch Pool state let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("Add liquidity: AMM Program expects valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); assert_eq!( vault_a.account_id, pool_def_data.vault_a_id, @@ -92,103 +92,21 @@ pub fn add_liquidity( "Add liquidity: current tick Account ID does not match PDA" ); - assert!( - max_amount_to_add_token_a != 0 && max_amount_to_add_token_b != 0, - "Both max-balances must be nonzero" - ); - let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Add liquidity", &vault_a, &vault_b); - - assert!( - vault_a_balance >= pool_def_data.reserve_a, - "Vaults' balances must be at least the reserve amounts" - ); - assert!( - vault_b_balance >= pool_def_data.reserve_b, - "Vaults' balances must be at least the reserve amounts" - ); - - // 2. Determine deposit amount - assert!(pool_def_data.reserve_a != 0, "Reserves must be nonzero"); - assert!(pool_def_data.reserve_b != 0, "Reserves must be nonzero"); - - // floor(reserve * max_amount / reserve), products widened to U256. Reserves are nonzero - // (asserted above), so the divisors are valid. - let ideal_a: u128 = mul_div_floor( - pool_def_data.reserve_a, - max_amount_to_add_token_b, - pool_def_data.reserve_b, - ); - let ideal_b: u128 = mul_div_floor( - pool_def_data.reserve_b, + let liquidity_quote = quote::add_liquidity( + &pool_def_data, + vault_a_balance, + vault_b_balance, max_amount_to_add_token_a, - pool_def_data.reserve_a, - ); - - let actual_amount_a = if ideal_a > max_amount_to_add_token_a { - max_amount_to_add_token_a - } else { - ideal_a - }; - let actual_amount_b = if ideal_b > max_amount_to_add_token_b { - max_amount_to_add_token_b - } else { - ideal_b - }; - - // 3. Validate amounts - assert!( - max_amount_to_add_token_a >= actual_amount_a, - "Actual trade amounts cannot exceed max_amounts" - ); - assert!( - max_amount_to_add_token_b >= actual_amount_b, - "Actual trade amounts cannot exceed max_amounts" - ); - - assert!(actual_amount_a != 0, "A trade amount is 0"); - assert!(actual_amount_b != 0, "A trade amount is 0"); - - // 4. Calculate LP to mint - // floor(supply * actual / reserve), products widened to U256. - let delta_lp = std::cmp::min( - mul_div_floor( - pool_def_data.liquidity_pool_supply, - actual_amount_a, - pool_def_data.reserve_a, - ), - mul_div_floor( - pool_def_data.liquidity_pool_supply, - actual_amount_b, - pool_def_data.reserve_b, - ), - ); - - assert!(delta_lp != 0, "Payable LP must be nonzero"); - - assert!( - delta_lp >= min_amount_liquidity.get(), - "Payable LP is less than provided minimum LP amount" - ); + max_amount_to_add_token_b, + min_amount_liquidity.get(), + ) + .unwrap_or_else(|error| panic!("{error}")); // 5. Update pool account let mut pool_post = pool.account.clone(); - let pool_post_definition = PoolDefinition { - liquidity_pool_supply: pool_def_data - .liquidity_pool_supply - .checked_add(delta_lp) - .expect("liquidity_pool_supply + delta_lp overflows u128"), - reserve_a: pool_def_data - .reserve_a - .checked_add(actual_amount_a) - .expect("reserve_a + actual_amount_a overflows u128"), - reserve_b: pool_def_data - .reserve_b - .checked_add(actual_amount_b) - .expect("reserve_b + actual_amount_b overflows u128"), - ..pool_def_data - }; + let pool_post_definition = liquidity_quote.pool.apply_to(&pool_def_data); pool_post.data = Data::from(&pool_post_definition); @@ -197,7 +115,7 @@ pub fn add_liquidity( token_program_id, vec![user_holding_a.clone(), vault_a.clone()], &token_core::Instruction::Transfer { - amount_to_transfer: actual_amount_a, + amount_to_transfer: liquidity_quote.actual_amount_a, }, ); // Chain call for Token B (UserHoldingB -> Vault_B) @@ -205,7 +123,7 @@ pub fn add_liquidity( token_program_id, vec![user_holding_b.clone(), vault_b.clone()], &token_core::Instruction::Transfer { - amount_to_transfer: actual_amount_b, + amount_to_transfer: liquidity_quote.actual_amount_b, }, ); // Chain call for LP (mint new tokens for user_holding_lp) @@ -215,17 +133,13 @@ pub fn add_liquidity( token_program_id, vec![pool_definition_lp_auth.clone(), user_holding_lp.clone()], &token_core::Instruction::Mint { - amount_to_mint: delta_lp, + amount_to_mint: liquidity_quote.liquidity_to_mint, }, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); // Refresh the pool's TWAP current tick from the post-add spot price. The pool is already owned // by this program, so it is passed (in its post-add state) as the authorized price source. - let new_price = spot_price_q64_64( - pool_post_definition.reserve_a, - pool_post_definition.reserve_b, - ); let pool_price_source = AccountWithMetadata { account: pool_post.clone(), is_authorized: true, @@ -238,7 +152,9 @@ pub fn add_liquidity( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + &twap_oracle_core::Instruction::UpdateCurrentTick { + price: liquidity_quote.pool.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( pool_def_data.definition_token_a_id, diff --git a/programs/amm/src/create_oracle_price_account.rs b/programs/amm/src/create_oracle_price_account.rs index f0c4a34..cc3ad47 100644 --- a/programs/amm/src/create_oracle_price_account.rs +++ b/programs/amm/src/create_oracle_price_account.rs @@ -1,13 +1,14 @@ use amm_core::{ - compute_config_pda, compute_pool_pda, compute_pool_pda_seed, spot_price_q64_64, AmmConfig, - PoolDefinition, + compute_config_pda, compute_pool_pda, compute_pool_pda_seed, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ account::{Account, AccountWithMetadata}, program::{AccountPostState, ChainedCall, ProgramId}, }; -use twap_oracle_core::{compute_oracle_price_account_pda, OBSERVATIONS_CAPACITY}; +use twap_oracle_core::compute_oracle_price_account_pda; + +use crate::quote; /// Creates a TWAP oracle price account for `pool` over a time window, on behalf of the AMM. /// @@ -38,10 +39,10 @@ use twap_oracle_core::{compute_oracle_price_account_pda, OBSERVATIONS_CAPACITY}; /// - `pool.account` has a zero token-A reserve (no spot price is defined). /// - the pool's spot price is zero (`reserve_b` is zero or negligible relative to `reserve_a`); /// zero is the no-price sentinel, so the account must never be seeded with it. -/// - `window_duration` is smaller than [`OBSERVATIONS_CAPACITY`]. Such a window can never have a -/// matching `PriceObservations` account, so the price account could never be updated by -/// `PublishPrice`. Checked here for an early AMM-level error, in addition to the oracle's own -/// check. +/// - `window_duration` is smaller than [`twap_oracle_core::OBSERVATIONS_CAPACITY`]. Such a window +/// can never have a matching `PriceObservations` account, so the price account could never be +/// updated by `PublishPrice`. Checked here for an early AMM-level error, in addition to the +/// oracle's own check. pub fn create_oracle_price_account( config: AccountWithMetadata, pool: AccountWithMetadata, @@ -67,15 +68,6 @@ pub fn create_oracle_price_account( "Create oracle price account: clock account must be the canonical 1-block LEZ clock account" ); - // A window smaller than the observations capacity can never have a matching PriceObservations - // account, so PublishPrice could never update the price account. Reject early with an AMM-level - // error; the oracle enforces the same bound. - assert!( - window_duration >= u64::from(OBSERVATIONS_CAPACITY), - "Create oracle price account: window_duration must be >= OBSERVATIONS_CAPACITY so a matching \ - PriceObservations account can exist and PublishPrice can update this price account" - ); - // The pool is the price source. Verify it is a genuine AMM pool PDA so we only ever authorize a // real pool as the source, and derive the asset pair and initial price from its validated // state. @@ -91,16 +83,8 @@ pub fn create_oracle_price_account( "Create oracle price account: Pool Account ID does not match PDA" ); - // Initial price is the pool's current spot price (quote per base), not caller-supplied. - let initial_price = spot_price_q64_64(pool_def.reserve_a, pool_def.reserve_b); - // A zero spot price is the sentinel consumers treat as "no valid price", so the account must - // never be seeded with it. This happens when `reserve_b` is zero or so small relative to - // `reserve_a` that the Q64.64 division floors to zero. The oracle enforces the same bound. - assert!( - initial_price != 0, - "Create oracle price account: pool spot price must be non-zero (zero is the no-price \ - sentinel; pool reserve_b is zero or negligible relative to reserve_a)" - ); + let oracle_quote = quote::create_oracle_price_account(&pool_def, window_duration) + .unwrap_or_else(|error| panic!("{error}")); // Verify the price account is the expected TWAP PDA for this (pool, window) pair and reject if // it already exists. @@ -128,10 +112,10 @@ pub fn create_oracle_price_account( clock.clone(), ], &twap_oracle_core::Instruction::CreateOraclePriceAccount { - base_asset: pool_def.definition_token_a_id, - quote_asset: pool_def.definition_token_b_id, - initial_price, - window_duration, + base_asset: oracle_quote.base_asset, + quote_asset: oracle_quote.quote_asset, + initial_price: oracle_quote.initial_price_q64_64, + window_duration: oracle_quote.window_duration, }, ) .with_pda_seeds(vec![compute_pool_pda_seed( @@ -151,8 +135,9 @@ pub fn create_oracle_price_account( #[cfg(test)] mod tests { - use amm_core::compute_pool_pda_seed; + use amm_core::{compute_pool_pda_seed, spot_price_q64_64}; use nssa_core::account::{Account, AccountId, Data, Nonce}; + use twap_oracle_core::OBSERVATIONS_CAPACITY; use super::*; @@ -418,8 +403,8 @@ mod tests { } /// A window smaller than `OBSERVATIONS_CAPACITY` can never have a matching `PriceObservations` - /// account, so the price account could never be updated by `PublishPrice`; it is rejected early - /// with an AMM-level error before the pool is even decoded. + /// account, so the price account could never be updated by `PublishPrice`; it is rejected with + /// an AMM-level error. #[test] #[should_panic(expected = "window_duration must be >= OBSERVATIONS_CAPACITY")] fn window_duration_below_capacity_panics() { diff --git a/programs/amm/src/lib.rs b/programs/amm/src/lib.rs index 787d0b0..c55b30d 100644 --- a/programs/amm/src/lib.rs +++ b/programs/amm/src/lib.rs @@ -1,4 +1,7 @@ //! The AMM Program implementation. +//! +//! Runtime handlers live in instruction-named modules. Host applications should use [`quote`] for +//! fallible deterministic previews backed by the same arithmetic as those handlers. pub use amm_core as core; @@ -7,6 +10,7 @@ pub mod create_oracle_price_account; pub mod create_price_observations; pub mod initialize; pub mod new_definition; +pub mod quote; pub mod remove; pub mod swap; pub mod sync; diff --git a/programs/amm/src/new_definition.rs b/programs/amm/src/new_definition.rs index 7afe9b2..64b9412 100644 --- a/programs/amm/src/new_definition.rs +++ b/programs/amm/src/new_definition.rs @@ -1,11 +1,9 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda, - compute_liquidity_token_pda_seed, compute_lp_lock_holding_pda, - compute_lp_lock_holding_pda_seed, compute_pool_pda, compute_pool_pda_seed, compute_vault_pda, - compute_vault_pda_seed, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition, - MINIMUM_LIQUIDITY, + compute_config_pda, compute_liquidity_token_pda, compute_liquidity_token_pda_seed, + compute_lp_lock_holding_pda, compute_lp_lock_holding_pda_seed, compute_pool_pda, + compute_pool_pda_seed, compute_vault_pda, compute_vault_pda_seed, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ @@ -15,6 +13,8 @@ use nssa_core::{ use token_core::TokenDefinition; use twap_oracle_core::compute_current_tick_account_pda; +use crate::quote; + #[expect( clippy::too_many_arguments, reason = "instruction surface passes explicit pool, vault, mint, lock, and user accounts" @@ -93,8 +93,6 @@ pub fn new_definition( compute_lp_lock_holding_pda(amm_program_id, pool.account_id), "LP lock holding Account ID does not match PDA" ); - assert_supported_fee_tier(fees); - // Assert that pool is uninitialized (hard precondition) assert_eq!( pool.account, @@ -118,16 +116,8 @@ pub fn new_definition( "New definition: clock account must be the canonical 1-block LEZ clock account" ); - // LP Token minting calculation. The `token_a * token_b` product is computed in U256 (via - // `isqrt_product`) so realistic 18-decimal amounts can't overflow u128 before the sqrt. - let initial_lp = isqrt_product(token_a_amount.get(), token_b_amount.get()); - assert!( - initial_lp > MINIMUM_LIQUIDITY, - "Initial liquidity must exceed minimum liquidity lock" - ); - let user_lp = initial_lp - .checked_sub(MINIMUM_LIQUIDITY) - .expect("initial liquidity must exceed minimum liquidity after validation"); + let pool_quote = quote::create_pool(token_a_amount.get(), token_b_amount.get(), fees) + .unwrap_or_else(|error| panic!("{error}")); // Update pool account let pool_post_definition = PoolDefinition { @@ -136,9 +126,9 @@ pub fn new_definition( vault_a_id: vault_a.account_id, vault_b_id: vault_b.account_id, liquidity_pool_id: pool_definition_lp.account_id, - liquidity_pool_supply: initial_lp, - reserve_a: token_a_amount.into(), - reserve_b: token_b_amount.into(), + liquidity_pool_supply: pool_quote.pool.liquidity_pool_supply, + reserve_a: pool_quote.pool.reserve_a, + reserve_b: pool_quote.pool.reserve_b, fees, }; @@ -192,7 +182,7 @@ pub fn new_definition( vec![pool_lp_auth.clone(), lp_lock_holding_auth], &token_core::Instruction::NewFungibleDefinition { name: String::from("LP Token"), - total_supply: MINIMUM_LIQUIDITY, + total_supply: pool_quote.locked_liquidity, mint_authority: Some(pool_definition_lp.account_id), }, ) @@ -205,7 +195,7 @@ pub fn new_definition( pool_lp_after_lock.account.program_owner = token_program_id; pool_lp_after_lock.account.data = Data::from(&TokenDefinition::Fungible { name: String::from("LP Token"), - total_supply: MINIMUM_LIQUIDITY, + total_supply: pool_quote.locked_liquidity, metadata_id: None, // Self-authority: the LP token is mintable only by the pool, which // presents this PDA as the authorized minter in the chained Mint call. @@ -215,7 +205,7 @@ pub fn new_definition( token_program_id, vec![pool_lp_after_lock, user_holding_lp.clone()], &token_core::Instruction::Mint { - amount_to_mint: user_lp, + amount_to_mint: pool_quote.user_liquidity, }, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); @@ -227,7 +217,6 @@ pub fn new_definition( // The pool is claimed (and thus owned by this program) by this same instruction, so the // chained call must present the pool in its post-claim state to match the accumulated state // diff: the runtime sets the claimed pool's owner to this program, so we predict that here. - let initial_price = spot_price_q64_64(token_a_amount.get(), token_b_amount.get()); let mut pool_price_source_account = pool_initialized; pool_price_source_account.program_owner = amm_program_id; let pool_price_source = AccountWithMetadata { @@ -242,7 +231,9 @@ pub fn new_definition( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::CreateCurrentTickAccount { initial_price }, + &twap_oracle_core::Instruction::CreateCurrentTickAccount { + initial_price: pool_quote.pool.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( definition_token_a_id, diff --git a/programs/amm/src/quote.rs b/programs/amm/src/quote.rs new file mode 100644 index 0000000..917beae --- /dev/null +++ b/programs/amm/src/quote.rs @@ -0,0 +1,932 @@ +//! Fallible, deterministic previews of AMM state transitions. +//! +//! These functions own the arithmetic used by the AMM instruction handlers. Host clients can call +//! the same functions to quote user operations without constructing runtime accounts or recovering +//! from guest-style assertion failures. Account ownership, signer/init constraints, deadlines, and +//! chained-call construction remain instruction-layer concerns. + +use std::{error::Error, fmt}; + +use amm_core::{ + checked_mul_div_ceil, checked_mul_div_floor, is_supported_fee_tier, isqrt_product, + spot_price_q64_64, PoolDefinition, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, +}; +use nssa_core::account::AccountId; +use twap_oracle_core::OBSERVATIONS_CAPACITY; + +/// A stable, machine-readable quote failure with its program-facing message. +/// +/// Consumers should branch on [`QuoteError::code`] and treat [`QuoteError::message`] as display or +/// diagnostic text. New codes may be added without changing this type's layout. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct QuoteError { + code: &'static str, + message: &'static str, +} + +impl QuoteError { + const fn new(code: &'static str, message: &'static str) -> Self { + Self { code, message } + } + + /// Returns the stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> &'static str { + self.code + } + + /// Returns the program-facing failure message. + #[must_use] + pub const fn message(&self) -> &'static str { + self.message + } +} + +impl fmt::Display for QuoteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message) + } +} + +impl Error for QuoteError {} + +/// A token pair's order relative to the pool's stored token A/B order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PairOrder { + /// The caller's first/second tokens are the pool's A/B tokens. + Stored, + /// The caller's first/second tokens are the pool's B/A tokens. + Reversed, +} + +impl PairOrder { + /// Converts caller-ordered raw amounts to the pool's stored A/B order. + #[must_use] + pub const fn amounts_to_stored(self, first: u128, second: u128) -> (u128, u128) { + match self { + Self::Stored => (first, second), + Self::Reversed => (second, first), + } + } + + /// Converts pool A/B raw amounts back to the caller's first/second order. + #[must_use] + pub const fn amounts_from_stored(self, amount_a: u128, amount_b: u128) -> (u128, u128) { + match self { + Self::Stored => (amount_a, amount_b), + Self::Reversed => (amount_b, amount_a), + } + } +} + +/// Resolves a caller token pair against a pool's stored token order. +pub fn pair_order( + pool: &PoolDefinition, + first_token_id: AccountId, + second_token_id: AccountId, +) -> Result { + if first_token_id == pool.definition_token_a_id && second_token_id == pool.definition_token_b_id + { + Ok(PairOrder::Stored) + } else if first_token_id == pool.definition_token_b_id + && second_token_id == pool.definition_token_a_id + { + Ok(PairOrder::Reversed) + } else { + Err(QuoteError::new( + "token_pair_not_in_pool", + "Token pair does not match the pool", + )) + } +} + +/// Swap direction relative to the pool's stored token A/B order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SwapDirection { + /// Deposit token A and withdraw token B. + AToB, + /// Deposit token B and withdraw token A. + BToA, +} + +/// Resolves swap direction from the input token definition. +pub fn swap_direction( + pool: &PoolDefinition, + input_token_id: AccountId, +) -> Result { + if input_token_id == pool.definition_token_a_id { + Ok(SwapDirection::AToB) + } else if input_token_id == pool.definition_token_b_id { + Ok(SwapDirection::BToA) + } else { + Err(QuoteError::new( + "input_token_not_in_pool", + "Input token is not part of the pool", + )) + } +} + +/// Pool scalar values after a quoted operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PoolUpdate { + /// Total LP supply after the operation. + pub liquidity_pool_supply: u128, + /// Stored token-A reserve after the operation. + pub reserve_a: u128, + /// Stored token-B reserve after the operation. + pub reserve_b: u128, + /// Token-B per token-A spot price after the operation, encoded as Q64.64. + pub spot_price_q64_64: u128, +} + +impl PoolUpdate { + /// Applies the quoted scalar values to a pool while preserving identity and fee fields. + #[must_use] + pub fn apply_to(&self, pool: &PoolDefinition) -> PoolDefinition { + PoolDefinition { + liquidity_pool_supply: self.liquidity_pool_supply, + reserve_a: self.reserve_a, + reserve_b: self.reserve_b, + ..pool.clone() + } + } +} + +/// Result of creating a pool's initial liquidity position. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CreatePoolQuote { + /// Initial pool scalar values. + pub pool: PoolUpdate, + /// LP tokens permanently assigned to the lock holding. + pub locked_liquidity: u128, + /// LP tokens minted to the pool creator. + pub user_liquidity: u128, +} + +/// Quotes the `NewDefinition` economic state transition. +pub fn create_pool( + token_a_amount: u128, + token_b_amount: u128, + fee_bps: u128, +) -> Result { + if token_a_amount == 0 { + return Err(QuoteError::new( + "token_a_amount_zero", + "token_a_amount must be nonzero", + )); + } + if token_b_amount == 0 { + return Err(QuoteError::new( + "token_b_amount_zero", + "token_b_amount must be nonzero", + )); + } + ensure_supported_fee_tier(fee_bps)?; + + let initial_liquidity = isqrt_product(token_a_amount, token_b_amount); + if initial_liquidity <= MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "initial_liquidity_too_low", + "Initial liquidity must exceed minimum liquidity lock", + )); + } + let user_liquidity = initial_liquidity + .checked_sub(MINIMUM_LIQUIDITY) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "initial liquidity must exceed minimum liquidity after validation", + ) + })?; + let pool = pool_update(initial_liquidity, token_a_amount, token_b_amount)?; + + Ok(CreatePoolQuote { + pool, + locked_liquidity: MINIMUM_LIQUIDITY, + user_liquidity, + }) +} + +/// Result of adding liquidity to an initialized pool. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AddLiquidityQuote { + /// Token-A amount transferred into the pool. + pub actual_amount_a: u128, + /// Token-B amount transferred into the pool. + pub actual_amount_b: u128, + /// LP amount minted to the caller. + pub liquidity_to_mint: u128, + /// Pool scalar values after the deposit. + pub pool: PoolUpdate, +} + +/// Previews `AddLiquidity` using the smallest executable LP guard. +/// +/// Use [`add_liquidity`] with the caller's slippage-derived guard before constructing an +/// instruction. +pub fn preview_add_liquidity( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + max_amount_a: u128, + max_amount_b: u128, +) -> Result { + add_liquidity( + pool, + vault_a_balance, + vault_b_balance, + max_amount_a, + max_amount_b, + 1, + ) +} + +/// Quotes the `AddLiquidity` economic state transition. +pub fn add_liquidity( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + max_amount_a: u128, + max_amount_b: u128, + minimum_liquidity: u128, +) -> Result { + ensure_supported_fee_tier(pool.fees)?; + if minimum_liquidity == 0 { + return Err(QuoteError::new( + "minimum_liquidity_zero", + "min_amount_liquidity must be nonzero", + )); + } + if max_amount_a == 0 || max_amount_b == 0 { + return Err(QuoteError::new( + "maximum_deposit_zero", + "Both max-balances must be nonzero", + )); + } + ensure_vault_balances( + pool, + vault_a_balance, + vault_b_balance, + "Vaults' balances must be at least the reserve amounts", + "Vaults' balances must be at least the reserve amounts", + )?; + if pool.reserve_a == 0 || pool.reserve_b == 0 { + return Err(QuoteError::new("reserve_zero", "Reserves must be nonzero")); + } + + let ideal_a = checked_floor( + pool.reserve_a, + max_amount_b, + pool.reserve_b, + "mul_div_floor result exceeds u128", + )?; + let ideal_b = checked_floor( + pool.reserve_b, + max_amount_a, + pool.reserve_a, + "mul_div_floor result exceeds u128", + )?; + let actual_amount_a = max_amount_a.min(ideal_a); + let actual_amount_b = max_amount_b.min(ideal_b); + if actual_amount_a == 0 || actual_amount_b == 0 { + return Err(QuoteError::new( + "deposit_amount_zero", + "A trade amount is 0", + )); + } + + let liquidity_from_a = checked_floor( + pool.liquidity_pool_supply, + actual_amount_a, + pool.reserve_a, + "mul_div_floor result exceeds u128", + )?; + let liquidity_from_b = checked_floor( + pool.liquidity_pool_supply, + actual_amount_b, + pool.reserve_b, + "mul_div_floor result exceeds u128", + )?; + let liquidity_to_mint = liquidity_from_a.min(liquidity_from_b); + if liquidity_to_mint == 0 { + return Err(QuoteError::new( + "minted_liquidity_zero", + "Payable LP must be nonzero", + )); + } + if liquidity_to_mint < minimum_liquidity { + return Err(QuoteError::new( + "minted_liquidity_below_minimum", + "Payable LP is less than provided minimum LP amount", + )); + } + + let liquidity_pool_supply = pool + .liquidity_pool_supply + .checked_add(liquidity_to_mint) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "liquidity_pool_supply + delta_lp overflows u128", + ) + })?; + let reserve_a = pool.reserve_a.checked_add(actual_amount_a).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_a + actual_amount_a overflows u128", + ) + })?; + let reserve_b = pool.reserve_b.checked_add(actual_amount_b).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_b + actual_amount_b overflows u128", + ) + })?; + + Ok(AddLiquidityQuote { + actual_amount_a, + actual_amount_b, + liquidity_to_mint, + pool: pool_update(liquidity_pool_supply, reserve_a, reserve_b)?, + }) +} + +/// Result of removing liquidity from a pool. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RemoveLiquidityQuote { + /// Token-A amount withdrawn from the pool. + pub withdraw_amount_a: u128, + /// Token-B amount withdrawn from the pool. + pub withdraw_amount_b: u128, + /// LP amount burned from the caller. + pub liquidity_to_burn: u128, + /// Pool scalar values after the withdrawal. + pub pool: PoolUpdate, +} + +/// Previews `RemoveLiquidity` using the smallest executable withdrawal guards. +/// +/// Use [`remove_liquidity`] with the caller's slippage-derived guards before constructing an +/// instruction. +pub fn preview_remove_liquidity( + pool: &PoolDefinition, + user_liquidity_balance: u128, + remove_liquidity_amount: u128, +) -> Result { + remove_liquidity(pool, user_liquidity_balance, remove_liquidity_amount, 1, 1) +} + +/// Quotes the `RemoveLiquidity` economic state transition. +pub fn remove_liquidity( + pool: &PoolDefinition, + user_liquidity_balance: u128, + remove_liquidity_amount: u128, + minimum_amount_a: u128, + minimum_amount_b: u128, +) -> Result { + ensure_supported_fee_tier(pool.fees)?; + if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "liquidity_supply_below_minimum", + "Pool liquidity supply is below minimum liquidity", + )); + } + if minimum_amount_a == 0 || minimum_amount_b == 0 { + return Err(QuoteError::new( + "minimum_withdrawal_zero", + "Minimum withdraw amount must be nonzero", + )); + } + if user_liquidity_balance > pool.liquidity_pool_supply { + return Err(QuoteError::new( + "invalid_liquidity_account", + "Invalid liquidity account provided", + )); + } + if pool.liquidity_pool_supply == MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "pool_contains_only_locked_liquidity", + "Pool only contains locked liquidity", + )); + } + if remove_liquidity_amount == 0 { + return Err(QuoteError::new( + "remove_liquidity_amount_zero", + "remove_liquidity_amount must be nonzero", + )); + } + if remove_liquidity_amount > user_liquidity_balance { + return Err(QuoteError::new( + "remove_amount_exceeds_user_balance", + "Remove amount exceeds user LP balance", + )); + } + let unlocked_liquidity = pool + .liquidity_pool_supply + .checked_sub(MINIMUM_LIQUIDITY) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "liquidity supply must be at least the locked minimum after validation", + ) + })?; + if remove_liquidity_amount > unlocked_liquidity { + return Err(QuoteError::new( + "remove_amount_exceeds_unlocked_liquidity", + "Cannot remove locked minimum liquidity", + )); + } + + let withdraw_amount_a = checked_floor( + pool.reserve_a, + remove_liquidity_amount, + pool.liquidity_pool_supply, + "mul_div_floor result exceeds u128", + )?; + let withdraw_amount_b = checked_floor( + pool.reserve_b, + remove_liquidity_amount, + pool.liquidity_pool_supply, + "mul_div_floor result exceeds u128", + )?; + if withdraw_amount_a < minimum_amount_a { + return Err(QuoteError::new( + "withdrawal_a_below_minimum", + "Insufficient minimal withdraw amount (Token A) provided for liquidity amount", + )); + } + if withdraw_amount_b < minimum_amount_b { + return Err(QuoteError::new( + "withdrawal_b_below_minimum", + "Insufficient minimal withdraw amount (Token B) provided for liquidity amount", + )); + } + + let liquidity_pool_supply = pool + .liquidity_pool_supply + .checked_sub(remove_liquidity_amount) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "liquidity_pool_supply - delta_lp underflows", + ) + })?; + let reserve_a = pool + .reserve_a + .checked_sub(withdraw_amount_a) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_a - withdraw_amount_a underflows", + ) + })?; + let reserve_b = pool + .reserve_b + .checked_sub(withdraw_amount_b) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_b - withdraw_amount_b underflows", + ) + })?; + + Ok(RemoveLiquidityQuote { + withdraw_amount_a, + withdraw_amount_b, + liquidity_to_burn: remove_liquidity_amount, + pool: pool_update(liquidity_pool_supply, reserve_a, reserve_b)?, + }) +} + +/// Result of either exact-input or exact-output swap quoting. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SwapQuote { + /// Direction relative to stored pool order. + pub direction: SwapDirection, + /// Gross amount transferred from the user. + pub amount_in: u128, + /// Input amount used by constant-product pricing after fee rounding. + pub effective_amount_in: u128, + /// Gross input retained as LP fee. + pub fee_amount: u128, + /// Amount transferred to the user. + pub amount_out: u128, + /// Pool scalar values after the trade. + pub pool: PoolUpdate, +} + +/// Previews `SwapExactInput` without a minimum-output guard. +/// +/// Use [`swap_exact_input`] with the caller's slippage-derived minimum before constructing an +/// instruction. +pub fn preview_swap_exact_input( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + direction: SwapDirection, + amount_in: u128, +) -> Result { + swap_exact_input( + pool, + vault_a_balance, + vault_b_balance, + direction, + amount_in, + 0, + ) +} + +/// Quotes a `SwapExactInput` state transition. +pub fn swap_exact_input( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + direction: SwapDirection, + amount_in: u128, + minimum_amount_out: u128, +) -> Result { + validate_swap_pool(pool, vault_a_balance, vault_b_balance)?; + let (reserve_in, reserve_out) = directional_reserves(pool, direction); + let fee_multiplier = fee_multiplier(pool.fees)?; + let effective_amount_in = checked_floor( + amount_in, + fee_multiplier, + FEE_BPS_DENOMINATOR, + "mul_div_floor result exceeds u128", + )?; + if effective_amount_in == 0 { + return Err(QuoteError::new( + "effective_swap_input_zero", + "Effective swap amount should be nonzero", + )); + } + let reserve_plus_effective = reserve_in.checked_add(effective_amount_in).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve + effective_amount_in overflows u128", + ) + })?; + let amount_out = checked_floor( + reserve_out, + effective_amount_in, + reserve_plus_effective, + "mul_div_floor result exceeds u128", + )?; + if amount_out < minimum_amount_out { + return Err(QuoteError::new( + "swap_output_below_minimum", + "Withdraw amount is less than minimal amount out", + )); + } + if amount_out == 0 { + return Err(QuoteError::new( + "swap_output_zero", + "Withdraw amount should be nonzero", + )); + } + + finish_swap_quote(pool, direction, amount_in, effective_amount_in, amount_out) +} + +/// Previews `SwapExactOutput` without a restrictive maximum-input guard. +/// +/// Use [`swap_exact_output`] with the caller's slippage-derived maximum before constructing an +/// instruction. +pub fn preview_swap_exact_output( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + direction: SwapDirection, + exact_amount_out: u128, +) -> Result { + swap_exact_output( + pool, + vault_a_balance, + vault_b_balance, + direction, + exact_amount_out, + u128::MAX, + ) +} + +/// Quotes a `SwapExactOutput` state transition. +pub fn swap_exact_output( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + direction: SwapDirection, + exact_amount_out: u128, + maximum_amount_in: u128, +) -> Result { + validate_swap_pool(pool, vault_a_balance, vault_b_balance)?; + if exact_amount_out == 0 { + return Err(QuoteError::new( + "exact_output_zero", + "Exact amount out must be nonzero", + )); + } + + let (reserve_in, reserve_out) = directional_reserves(pool, direction); + if exact_amount_out >= reserve_out { + return Err(QuoteError::new( + "exact_output_exceeds_reserve", + "Exact amount out exceeds reserve", + )); + } + let effective_input_denominator = + reserve_out.checked_sub(exact_amount_out).ok_or_else(|| { + QuoteError::new("arithmetic_overflow", "reserve_out - amount_out underflows") + })?; + let minimum_effective_input = checked_ceil( + reserve_in, + exact_amount_out, + effective_input_denominator, + "mul_div_ceil result exceeds u128", + )?; + let fee_multiplier = fee_multiplier(pool.fees)?; + let amount_in = checked_ceil( + minimum_effective_input, + FEE_BPS_DENOMINATOR, + fee_multiplier, + "mul_div_ceil result exceeds u128", + )?; + if amount_in > maximum_amount_in { + return Err(QuoteError::new( + "required_input_exceeds_maximum", + "Required input exceeds maximum amount in", + )); + } + let effective_amount_in = checked_floor( + amount_in, + fee_multiplier, + FEE_BPS_DENOMINATOR, + "mul_div_floor result exceeds u128", + )?; + + finish_swap_quote( + pool, + direction, + amount_in, + effective_amount_in, + exact_amount_out, + ) +} + +/// Result of synchronizing stored reserves to vault balances. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SyncReservesQuote { + /// Untracked token-A balance incorporated into the reserve. + pub donated_amount_a: u128, + /// Untracked token-B balance incorporated into the reserve. + pub donated_amount_b: u128, + /// Pool scalar values after synchronization. + pub pool: PoolUpdate, +} + +/// Quotes a `SyncReserves` state transition. +pub fn sync_reserves( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, +) -> Result { + ensure_supported_fee_tier(pool.fees)?; + if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "liquidity_supply_below_minimum", + "Pool liquidity supply is below minimum liquidity", + )); + } + ensure_vault_balances( + pool, + vault_a_balance, + vault_b_balance, + "Sync reserves: vault A balance is less than its reserve", + "Sync reserves: vault B balance is less than its reserve", + )?; + let donated_amount_a = vault_a_balance.checked_sub(pool.reserve_a).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "vault A balance - reserve A underflows", + ) + })?; + let donated_amount_b = vault_b_balance.checked_sub(pool.reserve_b).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "vault B balance - reserve B underflows", + ) + })?; + + Ok(SyncReservesQuote { + donated_amount_a, + donated_amount_b, + pool: pool_update(pool.liquidity_pool_supply, vault_a_balance, vault_b_balance)?, + }) +} + +/// Values used to initialize a pool-backed TWAP oracle price account. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OraclePriceAccountQuote { + /// Pool token A, used as the oracle base asset. + pub base_asset: AccountId, + /// Pool token B, used as the oracle quote asset. + pub quote_asset: AccountId, + /// Current pool spot price encoded as Q64.64. + pub initial_price_q64_64: u128, + /// Requested TWAP window duration in milliseconds. + pub window_duration: u64, +} + +/// Quotes values derived by `CreateOraclePriceAccount` from pool state. +pub fn create_oracle_price_account( + pool: &PoolDefinition, + window_duration: u64, +) -> Result { + if window_duration < u64::from(OBSERVATIONS_CAPACITY) { + return Err(QuoteError::new( + "oracle_window_too_short", + "Create oracle price account: window_duration must be >= OBSERVATIONS_CAPACITY so a matching PriceObservations account can exist and PublishPrice can update this price account", + )); + } + if pool.reserve_a == 0 { + return Err(QuoteError::new( + "reserve_a_zero", + "spot_price_q64_64: reserve_base must be non-zero", + )); + } + let initial_price_q64_64 = spot_price_q64_64(pool.reserve_a, pool.reserve_b); + if initial_price_q64_64 == 0 { + return Err(QuoteError::new( + "oracle_price_zero", + "Create oracle price account: pool spot price must be non-zero (zero is the no-price sentinel; pool reserve_b is zero or negligible relative to reserve_a)", + )); + } + + Ok(OraclePriceAccountQuote { + base_asset: pool.definition_token_a_id, + quote_asset: pool.definition_token_b_id, + initial_price_q64_64, + window_duration, + }) +} + +fn ensure_supported_fee_tier(fee_bps: u128) -> Result<(), QuoteError> { + if is_supported_fee_tier(fee_bps) { + Ok(()) + } else { + Err(QuoteError::new( + "unsupported_fee_tier", + "Fee tier must be one of 1, 5, 30, or 100 basis points", + )) + } +} + +fn ensure_vault_balances( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + vault_a_message: &'static str, + vault_b_message: &'static str, +) -> Result<(), QuoteError> { + if vault_a_balance < pool.reserve_a { + return Err(QuoteError::new( + "vault_a_balance_below_reserve", + vault_a_message, + )); + } + if vault_b_balance < pool.reserve_b { + return Err(QuoteError::new( + "vault_b_balance_below_reserve", + vault_b_message, + )); + } + + Ok(()) +} + +fn validate_swap_pool( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, +) -> Result<(), QuoteError> { + ensure_supported_fee_tier(pool.fees)?; + if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "liquidity_supply_below_minimum", + "Pool liquidity supply is below minimum liquidity", + )); + } + ensure_vault_balances( + pool, + vault_a_balance, + vault_b_balance, + "Reserve for Token A exceeds vault balance", + "Reserve for Token B exceeds vault balance", + ) +} + +fn directional_reserves(pool: &PoolDefinition, direction: SwapDirection) -> (u128, u128) { + match direction { + SwapDirection::AToB => (pool.reserve_a, pool.reserve_b), + SwapDirection::BToA => (pool.reserve_b, pool.reserve_a), + } +} + +fn finish_swap_quote( + pool: &PoolDefinition, + direction: SwapDirection, + amount_in: u128, + effective_amount_in: u128, + amount_out: u128, +) -> Result { + let fee_amount = amount_in.checked_sub(effective_amount_in).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "gross input - effective input underflows", + ) + })?; + let (reserve_a, reserve_b) = match direction { + SwapDirection::AToB => ( + pool.reserve_a.checked_add(amount_in).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_a + deposit_a overflows u128", + ) + })?, + pool.reserve_b.checked_sub(amount_out).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_b + deposit_b - withdraw_b underflows", + ) + })?, + ), + SwapDirection::BToA => ( + pool.reserve_a.checked_sub(amount_out).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_a + deposit_a - withdraw_a underflows", + ) + })?, + pool.reserve_b.checked_add(amount_in).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_b + deposit_b overflows u128", + ) + })?, + ), + }; + + Ok(SwapQuote { + direction, + amount_in, + effective_amount_in, + fee_amount, + amount_out, + pool: pool_update(pool.liquidity_pool_supply, reserve_a, reserve_b)?, + }) +} + +fn fee_multiplier(fee_bps: u128) -> Result { + FEE_BPS_DENOMINATOR + .checked_sub(fee_bps) + .ok_or_else(|| QuoteError::new("unsupported_fee_tier", "fee_bps exceeds fee denominator")) +} + +fn pool_update( + liquidity_pool_supply: u128, + reserve_a: u128, + reserve_b: u128, +) -> Result { + if reserve_a == 0 { + return Err(QuoteError::new( + "reserve_a_zero", + "spot_price_q64_64: reserve_base must be non-zero", + )); + } + + Ok(PoolUpdate { + liquidity_pool_supply, + reserve_a, + reserve_b, + spot_price_q64_64: spot_price_q64_64(reserve_a, reserve_b), + }) +} + +fn checked_floor( + left: u128, + right: u128, + denominator: u128, + overflow_message: &'static str, +) -> Result { + checked_mul_div_floor(left, right, denominator) + .ok_or_else(|| QuoteError::new("arithmetic_overflow", overflow_message)) +} + +fn checked_ceil( + left: u128, + right: u128, + denominator: u128, + overflow_message: &'static str, +) -> Result { + checked_mul_div_ceil(left, right, denominator) + .ok_or_else(|| QuoteError::new("arithmetic_overflow", overflow_message)) +} diff --git a/programs/amm/src/remove.rs b/programs/amm/src/remove.rs index 2d0bbf4..65ba5c5 100644 --- a/programs/amm/src/remove.rs +++ b/programs/amm/src/remove.rs @@ -1,9 +1,8 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, - compute_pool_pda_seed, compute_vault_pda_seed, mul_div_floor, spot_price_q64_64, AmmConfig, - PoolDefinition, MINIMUM_LIQUIDITY, + compute_config_pda, compute_liquidity_token_pda_seed, compute_pool_pda_seed, + compute_vault_pda_seed, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ @@ -12,6 +11,8 @@ use nssa_core::{ }; use twap_oracle_core::compute_current_tick_account_pda; +use crate::quote; + #[expect( clippy::too_many_arguments, reason = "instruction surface passes explicit pool, vault, and user accounts" @@ -49,12 +50,6 @@ pub fn remove_liquidity( // 1. Fetch Pool state let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("Remove liquidity: AMM Program expects a valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); - - assert!( - pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, - "Pool liquidity supply is below minimum liquidity" - ); assert_eq!( pool_def_data.liquidity_pool_id, pool_definition_lp.account_id, "LP definition mismatch" @@ -104,15 +99,6 @@ pub fn remove_liquidity( running_vault_a.is_authorized = true; running_vault_b.is_authorized = true; - assert!( - min_amount_to_remove_token_a != 0, - "Minimum withdraw amount must be nonzero" - ); - assert!( - min_amount_to_remove_token_b != 0, - "Minimum withdraw amount must be nonzero" - ); - // 2. Compute withdrawal amounts let user_holding_lp_data = token_core::TokenHolding::try_from(&user_holding_lp.account.data) .expect("Remove liquidity: AMM Program expects a valid Token Account for liquidity token"); @@ -126,79 +112,23 @@ pub fn remove_liquidity( ); }; - assert!( - user_lp_balance <= pool_def_data.liquidity_pool_supply, - "Invalid liquidity account provided" - ); assert_eq!( user_holding_lp_data.definition_id(), pool_def_data.liquidity_pool_id, "Invalid liquidity account provided" ); - // Honest flows should never reach the permanent lock through a valid remove instruction, but - // we still reject legacy or corrupted states that are already at the locked floor. - assert!( - pool_def_data.liquidity_pool_supply > MINIMUM_LIQUIDITY, - "Pool only contains locked liquidity" - ); - assert!( - remove_liquidity_amount <= user_lp_balance, - "Remove amount exceeds user LP balance" - ); - let unlocked_liquidity = pool_def_data - .liquidity_pool_supply - .checked_sub(MINIMUM_LIQUIDITY) - .expect("liquidity supply must be at least the locked minimum after validation"); - // The remove instruction never sees the LP lock account directly, so we must still refuse any - // request that would burn through the permanent floor even if ownership is already corrupted. - assert!( - remove_liquidity_amount <= unlocked_liquidity, - "Cannot remove locked minimum liquidity" - ); - - // floor(reserve * remove_amount / supply), products widened to U256. Supply exceeds - // MINIMUM_LIQUIDITY (asserted above), so the divisor is nonzero. - let withdraw_amount_a = mul_div_floor( - pool_def_data.reserve_a, + let liquidity_quote = quote::remove_liquidity( + &pool_def_data, + user_lp_balance, remove_liquidity_amount, - pool_def_data.liquidity_pool_supply, - ); - let withdraw_amount_b = mul_div_floor( - pool_def_data.reserve_b, - remove_liquidity_amount, - pool_def_data.liquidity_pool_supply, - ); - - // 3. Validate and slippage check - assert!( - withdraw_amount_a >= min_amount_to_remove_token_a, - "Insufficient minimal withdraw amount (Token A) provided for liquidity amount" - ); - assert!( - withdraw_amount_b >= min_amount_to_remove_token_b, - "Insufficient minimal withdraw amount (Token B) provided for liquidity amount" - ); - - // 4. Calculate LP to reduce cap by - let delta_lp: u128 = remove_liquidity_amount; + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + ) + .unwrap_or_else(|error| panic!("{error}")); // 5. Update pool account let mut pool_post = pool.account.clone(); - let pool_post_definition = PoolDefinition { - liquidity_pool_supply: pool_def_data - .liquidity_pool_supply - .checked_sub(delta_lp) - .expect("liquidity_pool_supply - delta_lp underflows"), - reserve_a: pool_def_data - .reserve_a - .checked_sub(withdraw_amount_a) - .expect("reserve_a - withdraw_amount_a underflows"), - reserve_b: pool_def_data - .reserve_b - .checked_sub(withdraw_amount_b) - .expect("reserve_b - withdraw_amount_b underflows"), - ..pool_def_data.clone() - }; + let pool_post_definition = liquidity_quote.pool.apply_to(&pool_def_data); pool_post.data = Data::from(&pool_post_definition); @@ -207,7 +137,7 @@ pub fn remove_liquidity( token_program_id, vec![running_vault_a, user_holding_a.clone()], &token_core::Instruction::Transfer { - amount_to_transfer: withdraw_amount_a, + amount_to_transfer: liquidity_quote.withdraw_amount_a, }, ) .with_pda_seeds(vec![compute_vault_pda_seed( @@ -219,7 +149,7 @@ pub fn remove_liquidity( token_program_id, vec![running_vault_b, user_holding_b.clone()], &token_core::Instruction::Transfer { - amount_to_transfer: withdraw_amount_b, + amount_to_transfer: liquidity_quote.withdraw_amount_b, }, ) .with_pda_seeds(vec![compute_vault_pda_seed( @@ -233,7 +163,7 @@ pub fn remove_liquidity( token_program_id, vec![pool_definition_lp_auth, user_holding_lp.clone()], &token_core::Instruction::Burn { - amount_to_burn: delta_lp, + amount_to_burn: liquidity_quote.liquidity_to_burn, }, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); @@ -241,10 +171,6 @@ pub fn remove_liquidity( // Refresh the pool's TWAP current tick from the post-removal spot price. The pool is already // owned by this program, so it is passed (in its post-removal state) as the authorized price // source. - let new_price = spot_price_q64_64( - pool_post_definition.reserve_a, - pool_post_definition.reserve_b, - ); let pool_price_source = AccountWithMetadata { account: pool_post.clone(), is_authorized: true, @@ -257,7 +183,9 @@ pub fn remove_liquidity( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + &twap_oracle_core::Instruction::UpdateCurrentTick { + price: liquidity_quote.pool.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( pool_def_data.definition_token_a_id, diff --git a/programs/amm/src/swap.rs b/programs/amm/src/swap.rs index 794198d..5882ddd 100644 --- a/programs/amm/src/swap.rs +++ b/programs/amm/src/swap.rs @@ -1,7 +1,5 @@ use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, - read_vault_fungible_balances, spot_price_q64_64, swap_exact_in_amounts, swap_exact_out_amounts, - AmmConfig, MINIMUM_LIQUIDITY, + compute_config_pda, compute_pool_pda_seed, read_vault_fungible_balances, AmmConfig, }; pub use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition}; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; @@ -11,20 +9,16 @@ use nssa_core::{ }; use twap_oracle_core::compute_current_tick_account_pda; -/// Validates swap setup: checks pool liquidity is ready, vaults match, and reserves are sufficient. +use crate::quote::{self, PoolUpdate, SwapDirection}; + +/// Decodes pool state, checks vault IDs, and reads vault balances for quote validation. fn validate_swap_setup( pool: &AccountWithMetadata, vault_a: &AccountWithMetadata, vault_b: &AccountWithMetadata, -) -> PoolDefinition { +) -> (PoolDefinition, u128, u128) { let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("AMM Program expects a valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); - - assert!( - pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, - "Pool liquidity supply is below minimum liquidity" - ); assert_eq!( vault_a.account_id, pool_def_data.vault_a_id, "Vault A was not provided" @@ -37,23 +31,14 @@ fn validate_swap_setup( let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Validate swap setup", vault_a, vault_b); - assert!( - vault_a_balance >= pool_def_data.reserve_a, - "Reserve for Token A exceeds vault balance" - ); - assert!( - vault_b_balance >= pool_def_data.reserve_b, - "Reserve for Token B exceeds vault balance" - ); - - pool_def_data + (pool_def_data, vault_a_balance, vault_b_balance) } /// Assembles the swap post-states (including the echoed current-tick and clock accounts) and the /// chained call that refreshes the pool's TWAP current tick from the post-swap spot price. #[expect( clippy::too_many_arguments, - reason = "post-state assembly keeps pool, vault, user, oracle, and delta state explicit" + reason = "post-state assembly keeps pool, vault, user, oracle, and quoted pool state explicit" )] #[expect( clippy::needless_pass_by_value, @@ -71,37 +56,16 @@ fn finalize_swap( user_holding_output: AccountWithMetadata, current_tick_account: AccountWithMetadata, clock: AccountWithMetadata, - deposit_a: u128, - withdraw_a: u128, - deposit_b: u128, - withdraw_b: u128, + pool_update: PoolUpdate, twap_oracle_program_id: ProgramId, ) -> (Vec, ChainedCall) { - let pool_post_definition = PoolDefinition { - reserve_a: pool_def_data - .reserve_a - .checked_add(deposit_a) - .expect("reserve_a + deposit_a overflows u128") - .checked_sub(withdraw_a) - .expect("reserve_a + deposit_a - withdraw_a underflows"), - reserve_b: pool_def_data - .reserve_b - .checked_add(deposit_b) - .expect("reserve_b + deposit_b overflows u128") - .checked_sub(withdraw_b) - .expect("reserve_b + deposit_b - withdraw_b underflows"), - ..pool_def_data - }; + let pool_post_definition = pool_update.apply_to(&pool_def_data); let mut pool_post = pool.account.clone(); pool_post.data = Data::from(&pool_post_definition); // Refresh the pool's TWAP current tick from the post-swap spot price. The pool is already owned // by this program, so it is passed (in its post-swap state) as the authorized price source. - let new_price = spot_price_q64_64( - pool_post_definition.reserve_a, - pool_post_definition.reserve_b, - ); let pool_price_source = AccountWithMetadata { account: pool_post.clone(), is_authorized: true, @@ -114,7 +78,9 @@ fn finalize_swap( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + &twap_oracle_core::Instruction::UpdateCurrentTick { + price: pool_update.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( pool_def_data.definition_token_a_id, @@ -153,7 +119,8 @@ pub fn swap_exact_input( min_amount_out: u128, amm_program_id: ProgramId, ) -> (Vec, Vec) { - let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b); + let (pool_def_data, vault_a_balance, vault_b_balance) = + validate_swap_setup(&pool, &vault_a, &vault_b); // The program IDs are taken from the config account, not trusted from a caller-supplied // account. Validating the config PDA is also the Program's initialization gate. @@ -181,12 +148,12 @@ pub fn swap_exact_input( let token_in_id = token_core::TokenHolding::try_from(&user_input_holding.account.data) .expect("Swap exact input: input holding must be a valid token holding") .definition_id(); - let (user_holding_a, user_holding_b) = if token_in_id == pool_def_data.definition_token_a_id { - (user_input_holding, user_output_holding) - } else if token_in_id == pool_def_data.definition_token_b_id { - (user_output_holding, user_input_holding) - } else { - panic!("Swap exact input: input holding token is not part of the pool"); + let direction = quote::swap_direction(&pool_def_data, token_in_id).unwrap_or_else(|_| { + panic!("Swap exact input: input holding token is not part of the pool") + }); + let (user_holding_a, user_holding_b) = match direction { + SwapDirection::AToB => (user_input_holding, user_output_holding), + SwapDirection::BToA => (user_output_holding, user_input_holding), }; assert_eq!( user_holding_a.account.program_owner, token_program_id, @@ -208,50 +175,43 @@ pub fn swap_exact_input( "Swap exact input: current tick Account ID does not match PDA" ); - let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) = - if token_in_id == pool_def_data.definition_token_a_id { - let (chained_calls, deposit_a, withdraw_b) = swap_logic( - user_holding_a.clone(), - vault_a.clone(), - vault_b.clone(), - user_holding_b.clone(), - swap_amount_in, - min_amount_out, - pool_def_data.fees, - pool_def_data.reserve_a, - pool_def_data.reserve_b, - pool.account_id, - ); - - (chained_calls, [deposit_a, 0], [0, withdraw_b]) - } else if token_in_id == pool_def_data.definition_token_b_id { - let (chained_calls, deposit_b, withdraw_a) = swap_logic( - user_holding_b.clone(), - vault_b.clone(), - vault_a.clone(), - user_holding_a.clone(), - swap_amount_in, - min_amount_out, - pool_def_data.fees, - pool_def_data.reserve_b, - pool_def_data.reserve_a, - pool.account_id, - ); - - (chained_calls, [0, withdraw_a], [deposit_b, 0]) - } else { - panic!("AccountId is not a token type for the pool"); - }; + let swap_quote = quote::swap_exact_input( + &pool_def_data, + vault_a_balance, + vault_b_balance, + direction, + swap_amount_in, + min_amount_out, + ) + .unwrap_or_else(|error| panic!("{error}")); + let chained_calls = match direction { + SwapDirection::AToB => swap_chained_calls( + user_holding_a.clone(), + vault_a.clone(), + vault_b.clone(), + user_holding_b.clone(), + swap_quote.amount_in, + swap_quote.amount_out, + pool.account_id, + ), + SwapDirection::BToA => swap_chained_calls( + user_holding_b.clone(), + vault_b.clone(), + vault_a.clone(), + user_holding_a.clone(), + swap_quote.amount_in, + swap_quote.amount_out, + pool.account_id, + ), + }; // Echo the two user holdings in the guest's declared slot order (input, then output) so the // framework matches each post-state to the right account. The a/b mapping above only drives the // reserve/vault bookkeeping; post-states are matched to accounts positionally. - let (user_holding_input, user_holding_output) = - if token_in_id == pool_def_data.definition_token_a_id { - (user_holding_a, user_holding_b) - } else { - (user_holding_b, user_holding_a) - }; + let (user_holding_input, user_holding_output) = match direction { + SwapDirection::AToB => (user_holding_a, user_holding_b), + SwapDirection::BToA => (user_holding_b, user_holding_a), + }; let (post_states, update_tick_call) = finalize_swap( config, pool, @@ -262,10 +222,7 @@ pub fn swap_exact_input( user_holding_output, current_tick_account, clock, - deposit_a, - withdraw_a, - deposit_b, - withdraw_b, + swap_quote.pool, twap_oracle_program_id, ); @@ -275,45 +232,15 @@ pub fn swap_exact_input( (post_states, chained_calls) } -#[expect( - clippy::too_many_arguments, - reason = "swap calculation keeps account context and pricing parameters explicit" -)] -fn swap_logic( +fn swap_chained_calls( user_deposit: AccountWithMetadata, vault_deposit: AccountWithMetadata, vault_withdraw: AccountWithMetadata, user_withdraw: AccountWithMetadata, - swap_amount_in: u128, - min_amount_out: u128, - fee_bps: u128, - reserve_deposit_vault_amount: u128, - reserve_withdraw_vault_amount: u128, + amount_in: u128, + amount_out: u128, pool_id: AccountId, -) -> (Vec, u128, u128) { - // Fee-adjust the input and price via constant product. Shared with the - // off-chain swap quote (`amm_core::swap_exact_in_amounts`) so the preview and - // the executed trade agree exactly. The recorded pool reserves are updated - // later with the full `swap_amount_in`, so LP fees accrue inside `reserve_*` - // via invariant growth rather than as a vault-balance surplus over `reserve_*`. - let (effective_amount_in, withdraw_amount) = swap_exact_in_amounts( - swap_amount_in, - reserve_deposit_vault_amount, - reserve_withdraw_vault_amount, - fee_bps, - ); - assert!( - effective_amount_in != 0, - "Effective swap amount should be nonzero" - ); - - // Slippage check - assert!( - min_amount_out <= withdraw_amount, - "Withdraw amount is less than minimal amount out" - ); - assert!(withdraw_amount != 0, "Withdraw amount should be nonzero"); - +) -> Vec { let token_program_id = user_deposit.account.program_owner; let mut chained_calls = Vec::new(); @@ -321,7 +248,7 @@ fn swap_logic( token_program_id, vec![user_deposit, vault_deposit], &token_core::Instruction::Transfer { - amount_to_transfer: swap_amount_in, + amount_to_transfer: amount_in, }, )); @@ -340,13 +267,13 @@ fn swap_logic( token_program_id, vec![vault_withdraw, user_withdraw], &token_core::Instruction::Transfer { - amount_to_transfer: withdraw_amount, + amount_to_transfer: amount_out, }, ) .with_pda_seeds(vec![pda_seed]), ); - (chained_calls, swap_amount_in, withdraw_amount) + chained_calls } #[expect( @@ -367,7 +294,8 @@ pub fn swap_exact_output( max_amount_in: u128, amm_program_id: ProgramId, ) -> (Vec, Vec) { - let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b); + let (pool_def_data, vault_a_balance, vault_b_balance) = + validate_swap_setup(&pool, &vault_a, &vault_b); // The program IDs are taken from the config account, not trusted from a caller-supplied // account. Validating the config PDA is also the Program's initialization gate. @@ -395,12 +323,12 @@ pub fn swap_exact_output( let token_in_id = token_core::TokenHolding::try_from(&user_input_holding.account.data) .expect("Swap exact output: input holding must be a valid token holding") .definition_id(); - let (user_holding_a, user_holding_b) = if token_in_id == pool_def_data.definition_token_a_id { - (user_input_holding, user_output_holding) - } else if token_in_id == pool_def_data.definition_token_b_id { - (user_output_holding, user_input_holding) - } else { - panic!("Swap exact output: input holding token is not part of the pool"); + let direction = quote::swap_direction(&pool_def_data, token_in_id).unwrap_or_else(|_| { + panic!("Swap exact output: input holding token is not part of the pool") + }); + let (user_holding_a, user_holding_b) = match direction { + SwapDirection::AToB => (user_input_holding, user_output_holding), + SwapDirection::BToA => (user_output_holding, user_input_holding), }; assert_eq!( user_holding_a.account.program_owner, token_program_id, @@ -422,50 +350,43 @@ pub fn swap_exact_output( "Swap exact output: current tick Account ID does not match PDA" ); - let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) = - if token_in_id == pool_def_data.definition_token_a_id { - let (chained_calls, deposit_a, withdraw_b) = exact_output_swap_logic( - user_holding_a.clone(), - vault_a.clone(), - vault_b.clone(), - user_holding_b.clone(), - exact_amount_out, - max_amount_in, - pool_def_data.reserve_a, - pool_def_data.reserve_b, - pool_def_data.fees, - pool.account_id, - ); - - (chained_calls, [deposit_a, 0], [0, withdraw_b]) - } else if token_in_id == pool_def_data.definition_token_b_id { - let (chained_calls, deposit_b, withdraw_a) = exact_output_swap_logic( - user_holding_b.clone(), - vault_b.clone(), - vault_a.clone(), - user_holding_a.clone(), - exact_amount_out, - max_amount_in, - pool_def_data.reserve_b, - pool_def_data.reserve_a, - pool_def_data.fees, - pool.account_id, - ); - - (chained_calls, [0, withdraw_a], [deposit_b, 0]) - } else { - panic!("AccountId is not a token type for the pool"); - }; + let swap_quote = quote::swap_exact_output( + &pool_def_data, + vault_a_balance, + vault_b_balance, + direction, + exact_amount_out, + max_amount_in, + ) + .unwrap_or_else(|error| panic!("{error}")); + let chained_calls = match direction { + SwapDirection::AToB => swap_chained_calls( + user_holding_a.clone(), + vault_a.clone(), + vault_b.clone(), + user_holding_b.clone(), + swap_quote.amount_in, + swap_quote.amount_out, + pool.account_id, + ), + SwapDirection::BToA => swap_chained_calls( + user_holding_b.clone(), + vault_b.clone(), + vault_a.clone(), + user_holding_a.clone(), + swap_quote.amount_in, + swap_quote.amount_out, + pool.account_id, + ), + }; // Echo the two user holdings in the guest's declared slot order (input, then output) so the // framework matches each post-state to the right account. The a/b mapping above only drives the // reserve/vault bookkeeping; post-states are matched to accounts positionally. - let (user_holding_input, user_holding_output) = - if token_in_id == pool_def_data.definition_token_a_id { - (user_holding_a, user_holding_b) - } else { - (user_holding_b, user_holding_a) - }; + let (user_holding_input, user_holding_output) = match direction { + SwapDirection::AToB => (user_holding_a, user_holding_b), + SwapDirection::BToA => (user_holding_b, user_holding_a), + }; let (post_states, update_tick_call) = finalize_swap( config, pool, @@ -476,10 +397,7 @@ pub fn swap_exact_output( user_holding_output, current_tick_account, clock, - deposit_a, - withdraw_a, - deposit_b, - withdraw_b, + swap_quote.pool, twap_oracle_program_id, ); @@ -488,80 +406,3 @@ pub fn swap_exact_output( (post_states, chained_calls) } - -#[expect( - clippy::too_many_arguments, - reason = "swap calculation keeps account context and pricing parameters explicit" -)] -fn exact_output_swap_logic( - user_deposit: AccountWithMetadata, - vault_deposit: AccountWithMetadata, - vault_withdraw: AccountWithMetadata, - user_withdraw: AccountWithMetadata, - exact_amount_out: u128, - max_amount_in: u128, - reserve_deposit_vault_amount: u128, - reserve_withdraw_vault_amount: u128, - fee_bps: u128, - pool_id: AccountId, -) -> (Vec, u128, u128) { - // Guard: exact_amount_out must be nonzero - assert_ne!(exact_amount_out, 0, "Exact amount out must be nonzero"); - - // Guard: exact_amount_out must be less than reserve_withdraw_vault_amount - assert!( - exact_amount_out < reserve_withdraw_vault_amount, - "Exact amount out exceeds reserve" - ); - - // Required gross input via the shared amm_core::swap_exact_out_amounts (same - // pricing as the off-chain exact-output quote). The `amount_out < reserve` - // guard above means it always resolves. - let (_, deposit_amount) = swap_exact_out_amounts( - exact_amount_out, - reserve_deposit_vault_amount, - reserve_withdraw_vault_amount, - fee_bps, - ) - .expect("swap exact output: reserves and fee must yield a valid input"); - - // Slippage check - assert!( - deposit_amount <= max_amount_in, - "Required input exceeds maximum amount in" - ); - - let token_program_id = user_deposit.account.program_owner; - - let mut chained_calls = Vec::new(); - chained_calls.push(ChainedCall::new( - token_program_id, - vec![user_deposit, vault_deposit], - &token_core::Instruction::Transfer { - amount_to_transfer: deposit_amount, - }, - )); - - let mut vault_withdraw = vault_withdraw; - vault_withdraw.is_authorized = true; - - let pda_seed = compute_vault_pda_seed( - pool_id, - token_core::TokenHolding::try_from(&vault_withdraw.account.data) - .expect("Exact Output Swap Logic: AMM Program expects valid token data") - .definition_id(), - ); - - chained_calls.push( - ChainedCall::new( - token_program_id, - vec![vault_withdraw, user_withdraw], - &token_core::Instruction::Transfer { - amount_to_transfer: exact_amount_out, - }, - ) - .with_pda_seeds(vec![pda_seed]), - ); - - (chained_calls, deposit_amount, exact_amount_out) -} diff --git a/programs/amm/src/sync.rs b/programs/amm/src/sync.rs index 250bfc8..b7d99a7 100644 --- a/programs/amm/src/sync.rs +++ b/programs/amm/src/sync.rs @@ -1,6 +1,6 @@ use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, - read_vault_fungible_balances, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, + compute_config_pda, compute_pool_pda_seed, read_vault_fungible_balances, AmmConfig, + PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ @@ -9,6 +9,8 @@ use nssa_core::{ }; use twap_oracle_core::compute_current_tick_account_pda; +use crate::quote; + pub fn sync_reserves( config: AccountWithMetadata, pool: AccountWithMetadata, @@ -20,7 +22,6 @@ pub fn sync_reserves( ) -> (Vec, Vec) { let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("Sync reserves: AMM Program expects a valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); // The TWAP oracle program ID is taken from the config account. Validating the config PDA is // also the Program's initialization gate. @@ -33,10 +34,6 @@ pub fn sync_reserves( .expect("Sync reserves: AMM Program must be initialized before use") .twap_oracle_program_id; - assert!( - pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, - "Pool liquidity supply is below minimum liquidity" - ); assert_eq!( vault_a.account_id, pool_def_data.vault_a_id, "Vault A was not provided" @@ -59,26 +56,14 @@ pub fn sync_reserves( let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Sync reserves", &vault_a, &vault_b); - assert!( - vault_a_balance >= pool_def_data.reserve_a, - "Sync reserves: vault A balance is less than its reserve" - ); - assert!( - vault_b_balance >= pool_def_data.reserve_b, - "Sync reserves: vault B balance is less than its reserve" - ); - - let pool_post_definition = PoolDefinition { - reserve_a: vault_a_balance, - reserve_b: vault_b_balance, - ..pool_def_data - }; + let sync_quote = quote::sync_reserves(&pool_def_data, vault_a_balance, vault_b_balance) + .unwrap_or_else(|error| panic!("{error}")); + let pool_post_definition = sync_quote.pool.apply_to(&pool_def_data); let mut pool_post = pool.account.clone(); pool_post.data = Data::from(&pool_post_definition); // Refresh the pool's TWAP current tick from the synced spot price. The pool is already owned by // this program, so it is passed (in its synced state) as the authorized price source. - let new_price = spot_price_q64_64(vault_a_balance, vault_b_balance); let pool_price_source = AccountWithMetadata { account: pool_post.clone(), is_authorized: true, @@ -91,7 +76,9 @@ pub fn sync_reserves( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + &twap_oracle_core::Instruction::UpdateCurrentTick { + price: sync_quote.pool.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( pool_def_data.definition_token_a_id, diff --git a/programs/amm/tests/quote_api.rs b/programs/amm/tests/quote_api.rs new file mode 100644 index 0000000..c8879d7 --- /dev/null +++ b/programs/amm/tests/quote_api.rs @@ -0,0 +1,241 @@ +use amm_program::{ + core::{spot_price_q64_64, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY}, + quote::{ + self, AddLiquidityQuote, CreatePoolQuote, PairOrder, PoolUpdate, RemoveLiquidityQuote, + SwapDirection, SwapQuote, SyncReservesQuote, + }, +}; +use nssa_core::account::AccountId; +use twap_oracle_core::OBSERVATIONS_CAPACITY; + +fn token_a_id() -> AccountId { + AccountId::new([1; 32]) +} + +fn token_b_id() -> AccountId { + AccountId::new([2; 32]) +} + +fn pool() -> PoolDefinition { + PoolDefinition { + definition_token_a_id: token_a_id(), + definition_token_b_id: token_b_id(), + vault_a_id: AccountId::new([3; 32]), + vault_b_id: AccountId::new([4; 32]), + liquidity_pool_id: AccountId::new([5; 32]), + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + } +} + +#[test] +fn create_pool_quotes_locked_and_user_liquidity() { + assert_eq!( + quote::create_pool(4_000, 9_000, FEE_TIER_BPS_30), + Ok(CreatePoolQuote { + pool: PoolUpdate { + liquidity_pool_supply: 6_000, + reserve_a: 4_000, + reserve_b: 9_000, + spot_price_q64_64: spot_price_q64_64(4_000, 9_000), + }, + locked_liquidity: MINIMUM_LIQUIDITY, + user_liquidity: 5_000, + }) + ); +} + +#[test] +fn add_liquidity_quotes_program_rounding_and_post_pool() { + assert_eq!( + quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 399), + Ok(AddLiquidityQuote { + actual_amount_a: 200, + actual_amount_b: 100, + liquidity_to_mint: 400, + pool: PoolUpdate { + liquidity_pool_supply: 2_400, + reserve_a: 1_200, + reserve_b: 600, + spot_price_q64_64: spot_price_q64_64(1_200, 600), + }, + }) + ); +} + +#[test] +fn preview_helpers_return_amounts_before_client_slippage_policy() { + let add = quote::preview_add_liquidity(&pool(), 1_000, 500, 400, 100) + .expect("valid add should preview"); + let remove = + quote::preview_remove_liquidity(&pool(), 1_000, 500).expect("valid removal should preview"); + let exact_input = + quote::preview_swap_exact_input(&pool(), 1_000, 500, SwapDirection::AToB, 100) + .expect("valid exact-input trade should preview"); + let exact_output = + quote::preview_swap_exact_output(&pool(), 1_000, 500, SwapDirection::AToB, 45) + .expect("valid exact-output trade should preview"); + + assert_eq!(add.liquidity_to_mint, 400); + assert_eq!(remove.withdraw_amount_a, 250); + assert_eq!(exact_input.amount_out, 45); + assert_eq!(exact_output.amount_in, 100); +} + +#[test] +fn remove_liquidity_quotes_program_rounding_and_post_pool() { + assert_eq!( + quote::remove_liquidity(&pool(), 1_000, 500, 250, 125), + Ok(RemoveLiquidityQuote { + withdraw_amount_a: 250, + withdraw_amount_b: 125, + liquidity_to_burn: 500, + pool: PoolUpdate { + liquidity_pool_supply: 1_500, + reserve_a: 750, + reserve_b: 375, + spot_price_q64_64: spot_price_q64_64(750, 375), + }, + }) + ); +} + +#[test] +fn exact_input_and_output_quotes_share_the_same_boundary() { + let expected = SwapQuote { + direction: SwapDirection::AToB, + amount_in: 100, + effective_amount_in: 99, + fee_amount: 1, + amount_out: 45, + pool: PoolUpdate { + liquidity_pool_supply: 2_000, + reserve_a: 1_100, + reserve_b: 455, + spot_price_q64_64: spot_price_q64_64(1_100, 455), + }, + }; + + assert_eq!( + quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::AToB, 100, 45), + Ok(expected) + ); + assert_eq!( + quote::swap_exact_output(&pool(), 1_000, 500, SwapDirection::AToB, 45, 100), + Ok(expected) + ); +} + +#[test] +fn reverse_swap_quote_keeps_pool_updates_in_stored_order() { + assert_eq!( + quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::BToA, 100, 165), + Ok(SwapQuote { + direction: SwapDirection::BToA, + amount_in: 100, + effective_amount_in: 99, + fee_amount: 1, + amount_out: 165, + pool: PoolUpdate { + liquidity_pool_supply: 2_000, + reserve_a: 835, + reserve_b: 600, + spot_price_q64_64: spot_price_q64_64(835, 600), + }, + }) + ); +} + +#[test] +fn sync_reserves_reports_donations_and_post_pool() { + assert_eq!( + quote::sync_reserves(&pool(), 1_100, 550), + Ok(SyncReservesQuote { + donated_amount_a: 100, + donated_amount_b: 50, + pool: PoolUpdate { + liquidity_pool_supply: 2_000, + reserve_a: 1_100, + reserve_b: 550, + spot_price_q64_64: spot_price_q64_64(1_100, 550), + }, + }) + ); +} + +#[test] +fn pair_and_swap_direction_follow_stored_pool_order() { + let pool = pool(); + + assert_eq!( + quote::pair_order(&pool, token_a_id(), token_b_id()), + Ok(PairOrder::Stored) + ); + assert_eq!( + quote::pair_order(&pool, token_b_id(), token_a_id()), + Ok(PairOrder::Reversed) + ); + assert_eq!( + quote::swap_direction(&pool, token_a_id()), + Ok(SwapDirection::AToB) + ); + assert_eq!( + quote::swap_direction(&pool, token_b_id()), + Ok(SwapDirection::BToA) + ); +} + +#[test] +fn oracle_price_quote_uses_pool_assets_and_spot_price() { + let window_duration = u64::from(OBSERVATIONS_CAPACITY); + let result = quote::create_oracle_price_account(&pool(), window_duration) + .expect("valid pool and window should quote"); + + assert_eq!(result.base_asset, token_a_id()); + assert_eq!(result.quote_asset, token_b_id()); + assert_eq!(result.initial_price_q64_64, spot_price_q64_64(1_000, 500)); + assert_eq!(result.window_duration, window_duration); +} + +#[test] +fn quote_errors_expose_stable_machine_codes() { + let error = quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 401) + .expect_err("minimum above minted liquidity must fail"); + + assert_eq!(error.code(), "minted_liquidity_below_minimum"); + assert_eq!( + error.message(), + "Payable LP is less than provided minimum LP amount" + ); +} + +#[test] +fn exact_quotes_apply_instruction_slippage_guards() { + let add = quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 401) + .expect_err("minimum LP above quote must fail"); + let remove = quote::remove_liquidity(&pool(), 1_000, 500, 251, 125) + .expect_err("minimum token A above quote must fail"); + let exact_input = quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::AToB, 100, 46) + .expect_err("minimum output above quote must fail"); + let exact_output = quote::swap_exact_output(&pool(), 1_000, 500, SwapDirection::AToB, 45, 99) + .expect_err("maximum input below quote must fail"); + + assert_eq!(add.code(), "minted_liquidity_below_minimum"); + assert_eq!(remove.code(), "withdrawal_a_below_minimum"); + assert_eq!(exact_input.code(), "swap_output_below_minimum"); + assert_eq!(exact_output.code(), "required_input_exceeds_maximum"); +} + +#[test] +fn arithmetic_overflow_is_returned_instead_of_panicking() { + let mut extreme_pool = pool(); + extreme_pool.reserve_a = u128::MAX; + extreme_pool.reserve_b = 1; + + let error = quote::add_liquidity(&extreme_pool, u128::MAX, 1, u128::MAX, u128::MAX, 1) + .expect_err("unrepresentable ideal amount must fail"); + + assert_eq!(error.code(), "arithmetic_overflow"); +}