refactor(amm): expose shared quote logic

This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-10 11:22:44 -03:00
parent 4363f13912
commit f1b15bebdd
10 changed files with 1425 additions and 552 deletions
+64 -16
View File
@@ -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<u128> {
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<u128> {
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.
+19 -103
View File
@@ -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,
+18 -33
View File
@@ -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() {
+4
View File
@@ -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;
+16 -25
View File
@@ -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,
+932
View File
@@ -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<PairOrder, QuoteError> {
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<SwapDirection, QuoteError> {
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<CreatePoolQuote, QuoteError> {
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<AddLiquidityQuote, QuoteError> {
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<AddLiquidityQuote, QuoteError> {
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<RemoveLiquidityQuote, QuoteError> {
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<RemoveLiquidityQuote, 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",
));
}
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<SwapQuote, QuoteError> {
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<SwapQuote, QuoteError> {
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<SwapQuote, QuoteError> {
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<SwapQuote, QuoteError> {
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<SyncReservesQuote, 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,
"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<OraclePriceAccountQuote, QuoteError> {
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<SwapQuote, QuoteError> {
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<u128, QuoteError> {
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<PoolUpdate, QuoteError> {
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<u128, QuoteError> {
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<u128, QuoteError> {
checked_mul_div_ceil(left, right, denominator)
.ok_or_else(|| QuoteError::new("arithmetic_overflow", overflow_message))
}
+18 -90
View File
@@ -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,
+103 -262
View File
@@ -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<AccountPostState>, 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<AccountPostState>, Vec<ChainedCall>) {
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<ChainedCall>, 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<ChainedCall> {
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<AccountPostState>, Vec<ChainedCall>) {
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<ChainedCall>, 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)
}
+10 -23
View File
@@ -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<AccountPostState>, Vec<ChainedCall>) {
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,
+241
View File
@@ -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");
}