mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
refactor(amm): expose shared quote logic
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user