From 26f7d332e4bbf052ae56186dcf78cdb0afb99385 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 12:12:04 -0300 Subject: [PATCH 1/8] refactor(amm): expose shared quote logic --- programs/amm/core/src/lib.rs | 76 +- programs/amm/src/add.rs | 122 +-- .../amm/src/create_oracle_price_account.rs | 51 +- programs/amm/src/lib.rs | 4 + programs/amm/src/new_definition.rs | 41 +- programs/amm/src/quote.rs | 932 ++++++++++++++++++ programs/amm/src/remove.rs | 108 +- programs/amm/src/swap.rs | 386 ++------ programs/amm/src/sync.rs | 33 +- programs/amm/tests/quote_api.rs | 241 +++++ 10 files changed, 1423 insertions(+), 571 deletions(-) create mode 100644 programs/amm/src/quote.rs create mode 100644 programs/amm/tests/quote_api.rs diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 4ca27656..de759351 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -256,12 +256,16 @@ pub const FEE_TIER_BPS_1: u128 = 1; pub const FEE_TIER_BPS_5: u128 = 5; pub const FEE_TIER_BPS_30: u128 = 30; pub const FEE_TIER_BPS_100: u128 = 100; +/// Fee tiers accepted by pool creation and all initialized-pool operations. +pub const SUPPORTED_FEE_TIERS: [u128; 4] = [ + FEE_TIER_BPS_1, + FEE_TIER_BPS_5, + FEE_TIER_BPS_30, + FEE_TIER_BPS_100, +]; pub fn is_supported_fee_tier(fees: u128) -> bool { - matches!( - fees, - FEE_TIER_BPS_1 | FEE_TIER_BPS_5 | FEE_TIER_BPS_30 | FEE_TIER_BPS_100 - ) + SUPPORTED_FEE_TIERS.contains(&fees) } pub fn assert_supported_fee_tier(fees: u128) { @@ -303,35 +307,65 @@ pub fn spot_price_q64_64(reserve_base: u128, reserve_quote: u128) -> u128 { /// `floor(a * b / c)` computed in U256 so the `a * b` product can't overflow u128. /// (Storage stays u128; only the intermediate widens.) /// -/// # Panics -/// Panics if `c` is zero, or if the result exceeds u128. +/// Returns `None` when `c` is zero or the quotient does not fit in `u128`. #[must_use] -pub fn mul_div_floor(a: u128, b: u128, c: u128) -> u128 { +pub fn checked_mul_div_floor(a: u128, b: u128, c: u128) -> Option { use alloy_primitives::U256; - assert!(c != 0, "mul_div_floor: divisor must be non-zero"); + + if c == 0 { + return None; + } + let product = U256::from(a) .checked_mul(U256::from(b)) .expect("u128 * u128 always fits in U256"); let result = product .checked_div(U256::from(c)) - .expect("mul_div_floor: divisor is non-zero after the assertion above"); - u128::try_from(result).expect("mul_div_floor result exceeds u128") + .expect("c is non-zero after the guard above"); + + u128::try_from(result).ok() } -/// `ceil(a * b / c)` computed in U256 so the `a * b` product can't overflow 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. #[must_use] -pub fn mul_div_ceil(a: u128, b: u128, c: u128) -> u128 { +pub fn mul_div_floor(a: u128, b: u128, c: u128) -> u128 { + assert!(c != 0, "mul_div_floor: divisor must be non-zero"); + checked_mul_div_floor(a, b, c).expect("mul_div_floor result exceeds u128") +} + +/// `ceil(a * b / c)` computed in U256 so the `a * b` product can't overflow u128. +/// (Storage stays u128; only the intermediate widens.) +/// +/// Returns `None` when `c` is zero or the quotient does not fit in `u128`. +#[must_use] +pub fn checked_mul_div_ceil(a: u128, b: u128, c: u128) -> Option { use alloy_primitives::U256; - assert!(c != 0, "mul_div_ceil: 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.div_ceil(U256::from(c)); - u128::try_from(result).expect("mul_div_ceil result exceeds u128") + + u128::try_from(result).ok() +} + +/// `ceil(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_ceil(a: u128, b: u128, c: u128) -> u128 { + assert!(c != 0, "mul_div_ceil: divisor must be non-zero"); + checked_mul_div_ceil(a, b, c).expect("mul_div_ceil result exceeds u128") } /// `floor(sqrt(a * b))` computed in U256 so the `a * b` product can't overflow u128. @@ -609,6 +643,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 @@ -643,6 +684,13 @@ mod tests { assert_eq!(mul_div_ceil(0, 12345, 7), 0); } + #[test] + fn checked_mul_div_ceil_reports_invalid_results() { + assert_eq!(checked_mul_div_ceil(1, 1, 0), None); + assert_eq!(checked_mul_div_ceil(u128::MAX, u128::MAX, 1), None); + assert_eq!(checked_mul_div_ceil(7, 7, 3), Some(17)); + } + #[test] fn mul_div_ceil_product_exceeds_u128() { // (2e30 * 2e30) / 2e30 = 2e30 exactly, fits in u128. diff --git a/programs/amm/src/add.rs b/programs/amm/src/add.rs index 80d03b33..445c0390 100644 --- a/programs/amm/src/add.rs +++ b/programs/amm/src/add.rs @@ -1,9 +1,8 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, - compute_pool_pda_seed, mul_div_floor, read_vault_fungible_balances, spot_price_q64_64, - AmmConfig, PoolDefinition, + compute_config_pda, compute_liquidity_token_pda_seed, compute_pool_pda_seed, + read_vault_fungible_balances, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ @@ -12,6 +11,8 @@ use nssa_core::{ }; use twap_oracle_core::compute_current_tick_account_pda; +use crate::quote; + #[expect( clippy::too_many_arguments, reason = "instruction surface passes explicit pool, vault, and user accounts" @@ -47,7 +48,6 @@ pub fn add_liquidity( // 1. Fetch Pool state let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("Add liquidity: AMM Program expects valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); assert_eq!( vault_a.account_id, pool_def_data.vault_a_id, @@ -92,103 +92,21 @@ pub fn add_liquidity( "Add liquidity: current tick Account ID does not match PDA" ); - assert!( - max_amount_to_add_token_a != 0 && max_amount_to_add_token_b != 0, - "Both max-balances must be nonzero" - ); - let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Add liquidity", &vault_a, &vault_b); - - assert!( - vault_a_balance >= pool_def_data.reserve_a, - "Vaults' balances must be at least the reserve amounts" - ); - assert!( - vault_b_balance >= pool_def_data.reserve_b, - "Vaults' balances must be at least the reserve amounts" - ); - - // 2. Determine deposit amount - assert!(pool_def_data.reserve_a != 0, "Reserves must be nonzero"); - assert!(pool_def_data.reserve_b != 0, "Reserves must be nonzero"); - - // floor(reserve * max_amount / reserve), products widened to U256. Reserves are nonzero - // (asserted above), so the divisors are valid. - let ideal_a: u128 = mul_div_floor( - pool_def_data.reserve_a, - max_amount_to_add_token_b, - pool_def_data.reserve_b, - ); - let ideal_b: u128 = mul_div_floor( - pool_def_data.reserve_b, + let liquidity_quote = quote::add_liquidity( + &pool_def_data, + vault_a_balance, + vault_b_balance, max_amount_to_add_token_a, - pool_def_data.reserve_a, - ); - - let actual_amount_a = if ideal_a > max_amount_to_add_token_a { - max_amount_to_add_token_a - } else { - ideal_a - }; - let actual_amount_b = if ideal_b > max_amount_to_add_token_b { - max_amount_to_add_token_b - } else { - ideal_b - }; - - // 3. Validate amounts - assert!( - max_amount_to_add_token_a >= actual_amount_a, - "Actual trade amounts cannot exceed max_amounts" - ); - assert!( - max_amount_to_add_token_b >= actual_amount_b, - "Actual trade amounts cannot exceed max_amounts" - ); - - assert!(actual_amount_a != 0, "A trade amount is 0"); - assert!(actual_amount_b != 0, "A trade amount is 0"); - - // 4. Calculate LP to mint - // floor(supply * actual / reserve), products widened to U256. - let delta_lp = std::cmp::min( - mul_div_floor( - pool_def_data.liquidity_pool_supply, - actual_amount_a, - pool_def_data.reserve_a, - ), - mul_div_floor( - pool_def_data.liquidity_pool_supply, - actual_amount_b, - pool_def_data.reserve_b, - ), - ); - - assert!(delta_lp != 0, "Payable LP must be nonzero"); - - assert!( - delta_lp >= min_amount_liquidity.get(), - "Payable LP is less than provided minimum LP amount" - ); + max_amount_to_add_token_b, + min_amount_liquidity.get(), + ) + .unwrap_or_else(|error| panic!("{error}")); // 5. Update pool account let mut pool_post = pool.account.clone(); - let pool_post_definition = PoolDefinition { - liquidity_pool_supply: pool_def_data - .liquidity_pool_supply - .checked_add(delta_lp) - .expect("liquidity_pool_supply + delta_lp overflows u128"), - reserve_a: pool_def_data - .reserve_a - .checked_add(actual_amount_a) - .expect("reserve_a + actual_amount_a overflows u128"), - reserve_b: pool_def_data - .reserve_b - .checked_add(actual_amount_b) - .expect("reserve_b + actual_amount_b overflows u128"), - ..pool_def_data - }; + let pool_post_definition = liquidity_quote.pool.apply_to(&pool_def_data); pool_post.data = Data::from(&pool_post_definition); @@ -197,7 +115,7 @@ pub fn add_liquidity( token_program_id, vec![user_holding_a.clone(), vault_a.clone()], &token_core::Instruction::Transfer { - amount_to_transfer: actual_amount_a, + amount_to_transfer: liquidity_quote.actual_amount_a, }, ); // Chain call for Token B (UserHoldingB -> Vault_B) @@ -205,7 +123,7 @@ pub fn add_liquidity( token_program_id, vec![user_holding_b.clone(), vault_b.clone()], &token_core::Instruction::Transfer { - amount_to_transfer: actual_amount_b, + amount_to_transfer: liquidity_quote.actual_amount_b, }, ); // Chain call for LP (mint new tokens for user_holding_lp) @@ -215,17 +133,13 @@ pub fn add_liquidity( token_program_id, vec![pool_definition_lp_auth.clone(), user_holding_lp.clone()], &token_core::Instruction::Mint { - amount_to_mint: delta_lp, + amount_to_mint: liquidity_quote.liquidity_to_mint, }, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); // Refresh the pool's TWAP current tick from the post-add spot price. The pool is already owned // by this program, so it is passed (in its post-add state) as the authorized price source. - let new_price = spot_price_q64_64( - pool_post_definition.reserve_a, - pool_post_definition.reserve_b, - ); let pool_price_source = AccountWithMetadata { account: pool_post.clone(), is_authorized: true, @@ -238,7 +152,9 @@ pub fn add_liquidity( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + &twap_oracle_core::Instruction::UpdateCurrentTick { + price: liquidity_quote.pool.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( pool_def_data.definition_token_a_id, diff --git a/programs/amm/src/create_oracle_price_account.rs b/programs/amm/src/create_oracle_price_account.rs index f0c4a348..cc3ad470 100644 --- a/programs/amm/src/create_oracle_price_account.rs +++ b/programs/amm/src/create_oracle_price_account.rs @@ -1,13 +1,14 @@ use amm_core::{ - compute_config_pda, compute_pool_pda, compute_pool_pda_seed, spot_price_q64_64, AmmConfig, - PoolDefinition, + compute_config_pda, compute_pool_pda, compute_pool_pda_seed, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ account::{Account, AccountWithMetadata}, program::{AccountPostState, ChainedCall, ProgramId}, }; -use twap_oracle_core::{compute_oracle_price_account_pda, OBSERVATIONS_CAPACITY}; +use twap_oracle_core::compute_oracle_price_account_pda; + +use crate::quote; /// Creates a TWAP oracle price account for `pool` over a time window, on behalf of the AMM. /// @@ -38,10 +39,10 @@ use twap_oracle_core::{compute_oracle_price_account_pda, OBSERVATIONS_CAPACITY}; /// - `pool.account` has a zero token-A reserve (no spot price is defined). /// - the pool's spot price is zero (`reserve_b` is zero or negligible relative to `reserve_a`); /// zero is the no-price sentinel, so the account must never be seeded with it. -/// - `window_duration` is smaller than [`OBSERVATIONS_CAPACITY`]. Such a window can never have a -/// matching `PriceObservations` account, so the price account could never be updated by -/// `PublishPrice`. Checked here for an early AMM-level error, in addition to the oracle's own -/// check. +/// - `window_duration` is smaller than [`twap_oracle_core::OBSERVATIONS_CAPACITY`]. Such a window +/// can never have a matching `PriceObservations` account, so the price account could never be +/// updated by `PublishPrice`. Checked here for an early AMM-level error, in addition to the +/// oracle's own check. pub fn create_oracle_price_account( config: AccountWithMetadata, pool: AccountWithMetadata, @@ -67,15 +68,6 @@ pub fn create_oracle_price_account( "Create oracle price account: clock account must be the canonical 1-block LEZ clock account" ); - // A window smaller than the observations capacity can never have a matching PriceObservations - // account, so PublishPrice could never update the price account. Reject early with an AMM-level - // error; the oracle enforces the same bound. - assert!( - window_duration >= u64::from(OBSERVATIONS_CAPACITY), - "Create oracle price account: window_duration must be >= OBSERVATIONS_CAPACITY so a matching \ - PriceObservations account can exist and PublishPrice can update this price account" - ); - // The pool is the price source. Verify it is a genuine AMM pool PDA so we only ever authorize a // real pool as the source, and derive the asset pair and initial price from its validated // state. @@ -91,16 +83,8 @@ pub fn create_oracle_price_account( "Create oracle price account: Pool Account ID does not match PDA" ); - // Initial price is the pool's current spot price (quote per base), not caller-supplied. - let initial_price = spot_price_q64_64(pool_def.reserve_a, pool_def.reserve_b); - // A zero spot price is the sentinel consumers treat as "no valid price", so the account must - // never be seeded with it. This happens when `reserve_b` is zero or so small relative to - // `reserve_a` that the Q64.64 division floors to zero. The oracle enforces the same bound. - assert!( - initial_price != 0, - "Create oracle price account: pool spot price must be non-zero (zero is the no-price \ - sentinel; pool reserve_b is zero or negligible relative to reserve_a)" - ); + let oracle_quote = quote::create_oracle_price_account(&pool_def, window_duration) + .unwrap_or_else(|error| panic!("{error}")); // Verify the price account is the expected TWAP PDA for this (pool, window) pair and reject if // it already exists. @@ -128,10 +112,10 @@ pub fn create_oracle_price_account( clock.clone(), ], &twap_oracle_core::Instruction::CreateOraclePriceAccount { - base_asset: pool_def.definition_token_a_id, - quote_asset: pool_def.definition_token_b_id, - initial_price, - window_duration, + base_asset: oracle_quote.base_asset, + quote_asset: oracle_quote.quote_asset, + initial_price: oracle_quote.initial_price_q64_64, + window_duration: oracle_quote.window_duration, }, ) .with_pda_seeds(vec![compute_pool_pda_seed( @@ -151,8 +135,9 @@ pub fn create_oracle_price_account( #[cfg(test)] mod tests { - use amm_core::compute_pool_pda_seed; + use amm_core::{compute_pool_pda_seed, spot_price_q64_64}; use nssa_core::account::{Account, AccountId, Data, Nonce}; + use twap_oracle_core::OBSERVATIONS_CAPACITY; use super::*; @@ -418,8 +403,8 @@ mod tests { } /// A window smaller than `OBSERVATIONS_CAPACITY` can never have a matching `PriceObservations` - /// account, so the price account could never be updated by `PublishPrice`; it is rejected early - /// with an AMM-level error before the pool is even decoded. + /// account, so the price account could never be updated by `PublishPrice`; it is rejected with + /// an AMM-level error. #[test] #[should_panic(expected = "window_duration must be >= OBSERVATIONS_CAPACITY")] fn window_duration_below_capacity_panics() { diff --git a/programs/amm/src/lib.rs b/programs/amm/src/lib.rs index 787d0b00..c55b30d4 100644 --- a/programs/amm/src/lib.rs +++ b/programs/amm/src/lib.rs @@ -1,4 +1,7 @@ //! The AMM Program implementation. +//! +//! Runtime handlers live in instruction-named modules. Host applications should use [`quote`] for +//! fallible deterministic previews backed by the same arithmetic as those handlers. pub use amm_core as core; @@ -7,6 +10,7 @@ pub mod create_oracle_price_account; pub mod create_price_observations; pub mod initialize; pub mod new_definition; +pub mod quote; pub mod remove; pub mod swap; pub mod sync; diff --git a/programs/amm/src/new_definition.rs b/programs/amm/src/new_definition.rs index 7afe9b23..64b9412b 100644 --- a/programs/amm/src/new_definition.rs +++ b/programs/amm/src/new_definition.rs @@ -1,11 +1,9 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda, - compute_liquidity_token_pda_seed, compute_lp_lock_holding_pda, - compute_lp_lock_holding_pda_seed, compute_pool_pda, compute_pool_pda_seed, compute_vault_pda, - compute_vault_pda_seed, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition, - MINIMUM_LIQUIDITY, + compute_config_pda, compute_liquidity_token_pda, compute_liquidity_token_pda_seed, + compute_lp_lock_holding_pda, compute_lp_lock_holding_pda_seed, compute_pool_pda, + compute_pool_pda_seed, compute_vault_pda, compute_vault_pda_seed, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ @@ -15,6 +13,8 @@ use nssa_core::{ use token_core::TokenDefinition; use twap_oracle_core::compute_current_tick_account_pda; +use crate::quote; + #[expect( clippy::too_many_arguments, reason = "instruction surface passes explicit pool, vault, mint, lock, and user accounts" @@ -93,8 +93,6 @@ pub fn new_definition( compute_lp_lock_holding_pda(amm_program_id, pool.account_id), "LP lock holding Account ID does not match PDA" ); - assert_supported_fee_tier(fees); - // Assert that pool is uninitialized (hard precondition) assert_eq!( pool.account, @@ -118,16 +116,8 @@ pub fn new_definition( "New definition: clock account must be the canonical 1-block LEZ clock account" ); - // LP Token minting calculation. The `token_a * token_b` product is computed in U256 (via - // `isqrt_product`) so realistic 18-decimal amounts can't overflow u128 before the sqrt. - let initial_lp = isqrt_product(token_a_amount.get(), token_b_amount.get()); - assert!( - initial_lp > MINIMUM_LIQUIDITY, - "Initial liquidity must exceed minimum liquidity lock" - ); - let user_lp = initial_lp - .checked_sub(MINIMUM_LIQUIDITY) - .expect("initial liquidity must exceed minimum liquidity after validation"); + let pool_quote = quote::create_pool(token_a_amount.get(), token_b_amount.get(), fees) + .unwrap_or_else(|error| panic!("{error}")); // Update pool account let pool_post_definition = PoolDefinition { @@ -136,9 +126,9 @@ pub fn new_definition( vault_a_id: vault_a.account_id, vault_b_id: vault_b.account_id, liquidity_pool_id: pool_definition_lp.account_id, - liquidity_pool_supply: initial_lp, - reserve_a: token_a_amount.into(), - reserve_b: token_b_amount.into(), + liquidity_pool_supply: pool_quote.pool.liquidity_pool_supply, + reserve_a: pool_quote.pool.reserve_a, + reserve_b: pool_quote.pool.reserve_b, fees, }; @@ -192,7 +182,7 @@ pub fn new_definition( vec![pool_lp_auth.clone(), lp_lock_holding_auth], &token_core::Instruction::NewFungibleDefinition { name: String::from("LP Token"), - total_supply: MINIMUM_LIQUIDITY, + total_supply: pool_quote.locked_liquidity, mint_authority: Some(pool_definition_lp.account_id), }, ) @@ -205,7 +195,7 @@ pub fn new_definition( pool_lp_after_lock.account.program_owner = token_program_id; pool_lp_after_lock.account.data = Data::from(&TokenDefinition::Fungible { name: String::from("LP Token"), - total_supply: MINIMUM_LIQUIDITY, + total_supply: pool_quote.locked_liquidity, metadata_id: None, // Self-authority: the LP token is mintable only by the pool, which // presents this PDA as the authorized minter in the chained Mint call. @@ -215,7 +205,7 @@ pub fn new_definition( token_program_id, vec![pool_lp_after_lock, user_holding_lp.clone()], &token_core::Instruction::Mint { - amount_to_mint: user_lp, + amount_to_mint: pool_quote.user_liquidity, }, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); @@ -227,7 +217,6 @@ pub fn new_definition( // The pool is claimed (and thus owned by this program) by this same instruction, so the // chained call must present the pool in its post-claim state to match the accumulated state // diff: the runtime sets the claimed pool's owner to this program, so we predict that here. - let initial_price = spot_price_q64_64(token_a_amount.get(), token_b_amount.get()); let mut pool_price_source_account = pool_initialized; pool_price_source_account.program_owner = amm_program_id; let pool_price_source = AccountWithMetadata { @@ -242,7 +231,9 @@ pub fn new_definition( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::CreateCurrentTickAccount { initial_price }, + &twap_oracle_core::Instruction::CreateCurrentTickAccount { + initial_price: pool_quote.pool.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( definition_token_a_id, diff --git a/programs/amm/src/quote.rs b/programs/amm/src/quote.rs new file mode 100644 index 00000000..917beae5 --- /dev/null +++ b/programs/amm/src/quote.rs @@ -0,0 +1,932 @@ +//! Fallible, deterministic previews of AMM state transitions. +//! +//! These functions own the arithmetic used by the AMM instruction handlers. Host clients can call +//! the same functions to quote user operations without constructing runtime accounts or recovering +//! from guest-style assertion failures. Account ownership, signer/init constraints, deadlines, and +//! chained-call construction remain instruction-layer concerns. + +use std::{error::Error, fmt}; + +use amm_core::{ + checked_mul_div_ceil, checked_mul_div_floor, is_supported_fee_tier, isqrt_product, + spot_price_q64_64, PoolDefinition, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, +}; +use nssa_core::account::AccountId; +use twap_oracle_core::OBSERVATIONS_CAPACITY; + +/// A stable, machine-readable quote failure with its program-facing message. +/// +/// Consumers should branch on [`QuoteError::code`] and treat [`QuoteError::message`] as display or +/// diagnostic text. New codes may be added without changing this type's layout. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct QuoteError { + code: &'static str, + message: &'static str, +} + +impl QuoteError { + const fn new(code: &'static str, message: &'static str) -> Self { + Self { code, message } + } + + /// Returns the stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> &'static str { + self.code + } + + /// Returns the program-facing failure message. + #[must_use] + pub const fn message(&self) -> &'static str { + self.message + } +} + +impl fmt::Display for QuoteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message) + } +} + +impl Error for QuoteError {} + +/// A token pair's order relative to the pool's stored token A/B order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PairOrder { + /// The caller's first/second tokens are the pool's A/B tokens. + Stored, + /// The caller's first/second tokens are the pool's B/A tokens. + Reversed, +} + +impl PairOrder { + /// Converts caller-ordered raw amounts to the pool's stored A/B order. + #[must_use] + pub const fn amounts_to_stored(self, first: u128, second: u128) -> (u128, u128) { + match self { + Self::Stored => (first, second), + Self::Reversed => (second, first), + } + } + + /// Converts pool A/B raw amounts back to the caller's first/second order. + #[must_use] + pub const fn amounts_from_stored(self, amount_a: u128, amount_b: u128) -> (u128, u128) { + match self { + Self::Stored => (amount_a, amount_b), + Self::Reversed => (amount_b, amount_a), + } + } +} + +/// Resolves a caller token pair against a pool's stored token order. +pub fn pair_order( + pool: &PoolDefinition, + first_token_id: AccountId, + second_token_id: AccountId, +) -> Result { + if first_token_id == pool.definition_token_a_id && second_token_id == pool.definition_token_b_id + { + Ok(PairOrder::Stored) + } else if first_token_id == pool.definition_token_b_id + && second_token_id == pool.definition_token_a_id + { + Ok(PairOrder::Reversed) + } else { + Err(QuoteError::new( + "token_pair_not_in_pool", + "Token pair does not match the pool", + )) + } +} + +/// Swap direction relative to the pool's stored token A/B order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SwapDirection { + /// Deposit token A and withdraw token B. + AToB, + /// Deposit token B and withdraw token A. + BToA, +} + +/// Resolves swap direction from the input token definition. +pub fn swap_direction( + pool: &PoolDefinition, + input_token_id: AccountId, +) -> Result { + if input_token_id == pool.definition_token_a_id { + Ok(SwapDirection::AToB) + } else if input_token_id == pool.definition_token_b_id { + Ok(SwapDirection::BToA) + } else { + Err(QuoteError::new( + "input_token_not_in_pool", + "Input token is not part of the pool", + )) + } +} + +/// Pool scalar values after a quoted operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PoolUpdate { + /// Total LP supply after the operation. + pub liquidity_pool_supply: u128, + /// Stored token-A reserve after the operation. + pub reserve_a: u128, + /// Stored token-B reserve after the operation. + pub reserve_b: u128, + /// Token-B per token-A spot price after the operation, encoded as Q64.64. + pub spot_price_q64_64: u128, +} + +impl PoolUpdate { + /// Applies the quoted scalar values to a pool while preserving identity and fee fields. + #[must_use] + pub fn apply_to(&self, pool: &PoolDefinition) -> PoolDefinition { + PoolDefinition { + liquidity_pool_supply: self.liquidity_pool_supply, + reserve_a: self.reserve_a, + reserve_b: self.reserve_b, + ..pool.clone() + } + } +} + +/// Result of creating a pool's initial liquidity position. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CreatePoolQuote { + /// Initial pool scalar values. + pub pool: PoolUpdate, + /// LP tokens permanently assigned to the lock holding. + pub locked_liquidity: u128, + /// LP tokens minted to the pool creator. + pub user_liquidity: u128, +} + +/// Quotes the `NewDefinition` economic state transition. +pub fn create_pool( + token_a_amount: u128, + token_b_amount: u128, + fee_bps: u128, +) -> Result { + if token_a_amount == 0 { + return Err(QuoteError::new( + "token_a_amount_zero", + "token_a_amount must be nonzero", + )); + } + if token_b_amount == 0 { + return Err(QuoteError::new( + "token_b_amount_zero", + "token_b_amount must be nonzero", + )); + } + ensure_supported_fee_tier(fee_bps)?; + + let initial_liquidity = isqrt_product(token_a_amount, token_b_amount); + if initial_liquidity <= MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "initial_liquidity_too_low", + "Initial liquidity must exceed minimum liquidity lock", + )); + } + let user_liquidity = initial_liquidity + .checked_sub(MINIMUM_LIQUIDITY) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "initial liquidity must exceed minimum liquidity after validation", + ) + })?; + let pool = pool_update(initial_liquidity, token_a_amount, token_b_amount)?; + + Ok(CreatePoolQuote { + pool, + locked_liquidity: MINIMUM_LIQUIDITY, + user_liquidity, + }) +} + +/// Result of adding liquidity to an initialized pool. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AddLiquidityQuote { + /// Token-A amount transferred into the pool. + pub actual_amount_a: u128, + /// Token-B amount transferred into the pool. + pub actual_amount_b: u128, + /// LP amount minted to the caller. + pub liquidity_to_mint: u128, + /// Pool scalar values after the deposit. + pub pool: PoolUpdate, +} + +/// Previews `AddLiquidity` using the smallest executable LP guard. +/// +/// Use [`add_liquidity`] with the caller's slippage-derived guard before constructing an +/// instruction. +pub fn preview_add_liquidity( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + max_amount_a: u128, + max_amount_b: u128, +) -> Result { + add_liquidity( + pool, + vault_a_balance, + vault_b_balance, + max_amount_a, + max_amount_b, + 1, + ) +} + +/// Quotes the `AddLiquidity` economic state transition. +pub fn add_liquidity( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + max_amount_a: u128, + max_amount_b: u128, + minimum_liquidity: u128, +) -> Result { + ensure_supported_fee_tier(pool.fees)?; + if minimum_liquidity == 0 { + return Err(QuoteError::new( + "minimum_liquidity_zero", + "min_amount_liquidity must be nonzero", + )); + } + if max_amount_a == 0 || max_amount_b == 0 { + return Err(QuoteError::new( + "maximum_deposit_zero", + "Both max-balances must be nonzero", + )); + } + ensure_vault_balances( + pool, + vault_a_balance, + vault_b_balance, + "Vaults' balances must be at least the reserve amounts", + "Vaults' balances must be at least the reserve amounts", + )?; + if pool.reserve_a == 0 || pool.reserve_b == 0 { + return Err(QuoteError::new("reserve_zero", "Reserves must be nonzero")); + } + + let ideal_a = checked_floor( + pool.reserve_a, + max_amount_b, + pool.reserve_b, + "mul_div_floor result exceeds u128", + )?; + let ideal_b = checked_floor( + pool.reserve_b, + max_amount_a, + pool.reserve_a, + "mul_div_floor result exceeds u128", + )?; + let actual_amount_a = max_amount_a.min(ideal_a); + let actual_amount_b = max_amount_b.min(ideal_b); + if actual_amount_a == 0 || actual_amount_b == 0 { + return Err(QuoteError::new( + "deposit_amount_zero", + "A trade amount is 0", + )); + } + + let liquidity_from_a = checked_floor( + pool.liquidity_pool_supply, + actual_amount_a, + pool.reserve_a, + "mul_div_floor result exceeds u128", + )?; + let liquidity_from_b = checked_floor( + pool.liquidity_pool_supply, + actual_amount_b, + pool.reserve_b, + "mul_div_floor result exceeds u128", + )?; + let liquidity_to_mint = liquidity_from_a.min(liquidity_from_b); + if liquidity_to_mint == 0 { + return Err(QuoteError::new( + "minted_liquidity_zero", + "Payable LP must be nonzero", + )); + } + if liquidity_to_mint < minimum_liquidity { + return Err(QuoteError::new( + "minted_liquidity_below_minimum", + "Payable LP is less than provided minimum LP amount", + )); + } + + let liquidity_pool_supply = pool + .liquidity_pool_supply + .checked_add(liquidity_to_mint) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "liquidity_pool_supply + delta_lp overflows u128", + ) + })?; + let reserve_a = pool.reserve_a.checked_add(actual_amount_a).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_a + actual_amount_a overflows u128", + ) + })?; + let reserve_b = pool.reserve_b.checked_add(actual_amount_b).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_b + actual_amount_b overflows u128", + ) + })?; + + Ok(AddLiquidityQuote { + actual_amount_a, + actual_amount_b, + liquidity_to_mint, + pool: pool_update(liquidity_pool_supply, reserve_a, reserve_b)?, + }) +} + +/// Result of removing liquidity from a pool. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RemoveLiquidityQuote { + /// Token-A amount withdrawn from the pool. + pub withdraw_amount_a: u128, + /// Token-B amount withdrawn from the pool. + pub withdraw_amount_b: u128, + /// LP amount burned from the caller. + pub liquidity_to_burn: u128, + /// Pool scalar values after the withdrawal. + pub pool: PoolUpdate, +} + +/// Previews `RemoveLiquidity` using the smallest executable withdrawal guards. +/// +/// Use [`remove_liquidity`] with the caller's slippage-derived guards before constructing an +/// instruction. +pub fn preview_remove_liquidity( + pool: &PoolDefinition, + user_liquidity_balance: u128, + remove_liquidity_amount: u128, +) -> Result { + remove_liquidity(pool, user_liquidity_balance, remove_liquidity_amount, 1, 1) +} + +/// Quotes the `RemoveLiquidity` economic state transition. +pub fn remove_liquidity( + pool: &PoolDefinition, + user_liquidity_balance: u128, + remove_liquidity_amount: u128, + minimum_amount_a: u128, + minimum_amount_b: u128, +) -> Result { + ensure_supported_fee_tier(pool.fees)?; + if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "liquidity_supply_below_minimum", + "Pool liquidity supply is below minimum liquidity", + )); + } + if minimum_amount_a == 0 || minimum_amount_b == 0 { + return Err(QuoteError::new( + "minimum_withdrawal_zero", + "Minimum withdraw amount must be nonzero", + )); + } + if user_liquidity_balance > pool.liquidity_pool_supply { + return Err(QuoteError::new( + "invalid_liquidity_account", + "Invalid liquidity account provided", + )); + } + if pool.liquidity_pool_supply == MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "pool_contains_only_locked_liquidity", + "Pool only contains locked liquidity", + )); + } + if remove_liquidity_amount == 0 { + return Err(QuoteError::new( + "remove_liquidity_amount_zero", + "remove_liquidity_amount must be nonzero", + )); + } + if remove_liquidity_amount > user_liquidity_balance { + return Err(QuoteError::new( + "remove_amount_exceeds_user_balance", + "Remove amount exceeds user LP balance", + )); + } + let unlocked_liquidity = pool + .liquidity_pool_supply + .checked_sub(MINIMUM_LIQUIDITY) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "liquidity supply must be at least the locked minimum after validation", + ) + })?; + if remove_liquidity_amount > unlocked_liquidity { + return Err(QuoteError::new( + "remove_amount_exceeds_unlocked_liquidity", + "Cannot remove locked minimum liquidity", + )); + } + + let withdraw_amount_a = checked_floor( + pool.reserve_a, + remove_liquidity_amount, + pool.liquidity_pool_supply, + "mul_div_floor result exceeds u128", + )?; + let withdraw_amount_b = checked_floor( + pool.reserve_b, + remove_liquidity_amount, + pool.liquidity_pool_supply, + "mul_div_floor result exceeds u128", + )?; + if withdraw_amount_a < minimum_amount_a { + return Err(QuoteError::new( + "withdrawal_a_below_minimum", + "Insufficient minimal withdraw amount (Token A) provided for liquidity amount", + )); + } + if withdraw_amount_b < minimum_amount_b { + return Err(QuoteError::new( + "withdrawal_b_below_minimum", + "Insufficient minimal withdraw amount (Token B) provided for liquidity amount", + )); + } + + let liquidity_pool_supply = pool + .liquidity_pool_supply + .checked_sub(remove_liquidity_amount) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "liquidity_pool_supply - delta_lp underflows", + ) + })?; + let reserve_a = pool + .reserve_a + .checked_sub(withdraw_amount_a) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_a - withdraw_amount_a underflows", + ) + })?; + let reserve_b = pool + .reserve_b + .checked_sub(withdraw_amount_b) + .ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_b - withdraw_amount_b underflows", + ) + })?; + + Ok(RemoveLiquidityQuote { + withdraw_amount_a, + withdraw_amount_b, + liquidity_to_burn: remove_liquidity_amount, + pool: pool_update(liquidity_pool_supply, reserve_a, reserve_b)?, + }) +} + +/// Result of either exact-input or exact-output swap quoting. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SwapQuote { + /// Direction relative to stored pool order. + pub direction: SwapDirection, + /// Gross amount transferred from the user. + pub amount_in: u128, + /// Input amount used by constant-product pricing after fee rounding. + pub effective_amount_in: u128, + /// Gross input retained as LP fee. + pub fee_amount: u128, + /// Amount transferred to the user. + pub amount_out: u128, + /// Pool scalar values after the trade. + pub pool: PoolUpdate, +} + +/// Previews `SwapExactInput` without a minimum-output guard. +/// +/// Use [`swap_exact_input`] with the caller's slippage-derived minimum before constructing an +/// instruction. +pub fn preview_swap_exact_input( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + direction: SwapDirection, + amount_in: u128, +) -> Result { + swap_exact_input( + pool, + vault_a_balance, + vault_b_balance, + direction, + amount_in, + 0, + ) +} + +/// Quotes a `SwapExactInput` state transition. +pub fn swap_exact_input( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + direction: SwapDirection, + amount_in: u128, + minimum_amount_out: u128, +) -> Result { + validate_swap_pool(pool, vault_a_balance, vault_b_balance)?; + let (reserve_in, reserve_out) = directional_reserves(pool, direction); + let fee_multiplier = fee_multiplier(pool.fees)?; + let effective_amount_in = checked_floor( + amount_in, + fee_multiplier, + FEE_BPS_DENOMINATOR, + "mul_div_floor result exceeds u128", + )?; + if effective_amount_in == 0 { + return Err(QuoteError::new( + "effective_swap_input_zero", + "Effective swap amount should be nonzero", + )); + } + let reserve_plus_effective = reserve_in.checked_add(effective_amount_in).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve + effective_amount_in overflows u128", + ) + })?; + let amount_out = checked_floor( + reserve_out, + effective_amount_in, + reserve_plus_effective, + "mul_div_floor result exceeds u128", + )?; + if amount_out < minimum_amount_out { + return Err(QuoteError::new( + "swap_output_below_minimum", + "Withdraw amount is less than minimal amount out", + )); + } + if amount_out == 0 { + return Err(QuoteError::new( + "swap_output_zero", + "Withdraw amount should be nonzero", + )); + } + + finish_swap_quote(pool, direction, amount_in, effective_amount_in, amount_out) +} + +/// Previews `SwapExactOutput` without a restrictive maximum-input guard. +/// +/// Use [`swap_exact_output`] with the caller's slippage-derived maximum before constructing an +/// instruction. +pub fn preview_swap_exact_output( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + direction: SwapDirection, + exact_amount_out: u128, +) -> Result { + swap_exact_output( + pool, + vault_a_balance, + vault_b_balance, + direction, + exact_amount_out, + u128::MAX, + ) +} + +/// Quotes a `SwapExactOutput` state transition. +pub fn swap_exact_output( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + direction: SwapDirection, + exact_amount_out: u128, + maximum_amount_in: u128, +) -> Result { + validate_swap_pool(pool, vault_a_balance, vault_b_balance)?; + if exact_amount_out == 0 { + return Err(QuoteError::new( + "exact_output_zero", + "Exact amount out must be nonzero", + )); + } + + let (reserve_in, reserve_out) = directional_reserves(pool, direction); + if exact_amount_out >= reserve_out { + return Err(QuoteError::new( + "exact_output_exceeds_reserve", + "Exact amount out exceeds reserve", + )); + } + let effective_input_denominator = + reserve_out.checked_sub(exact_amount_out).ok_or_else(|| { + QuoteError::new("arithmetic_overflow", "reserve_out - amount_out underflows") + })?; + let minimum_effective_input = checked_ceil( + reserve_in, + exact_amount_out, + effective_input_denominator, + "mul_div_ceil result exceeds u128", + )?; + let fee_multiplier = fee_multiplier(pool.fees)?; + let amount_in = checked_ceil( + minimum_effective_input, + FEE_BPS_DENOMINATOR, + fee_multiplier, + "mul_div_ceil result exceeds u128", + )?; + if amount_in > maximum_amount_in { + return Err(QuoteError::new( + "required_input_exceeds_maximum", + "Required input exceeds maximum amount in", + )); + } + let effective_amount_in = checked_floor( + amount_in, + fee_multiplier, + FEE_BPS_DENOMINATOR, + "mul_div_floor result exceeds u128", + )?; + + finish_swap_quote( + pool, + direction, + amount_in, + effective_amount_in, + exact_amount_out, + ) +} + +/// Result of synchronizing stored reserves to vault balances. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SyncReservesQuote { + /// Untracked token-A balance incorporated into the reserve. + pub donated_amount_a: u128, + /// Untracked token-B balance incorporated into the reserve. + pub donated_amount_b: u128, + /// Pool scalar values after synchronization. + pub pool: PoolUpdate, +} + +/// Quotes a `SyncReserves` state transition. +pub fn sync_reserves( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, +) -> Result { + ensure_supported_fee_tier(pool.fees)?; + if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "liquidity_supply_below_minimum", + "Pool liquidity supply is below minimum liquidity", + )); + } + ensure_vault_balances( + pool, + vault_a_balance, + vault_b_balance, + "Sync reserves: vault A balance is less than its reserve", + "Sync reserves: vault B balance is less than its reserve", + )?; + let donated_amount_a = vault_a_balance.checked_sub(pool.reserve_a).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "vault A balance - reserve A underflows", + ) + })?; + let donated_amount_b = vault_b_balance.checked_sub(pool.reserve_b).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "vault B balance - reserve B underflows", + ) + })?; + + Ok(SyncReservesQuote { + donated_amount_a, + donated_amount_b, + pool: pool_update(pool.liquidity_pool_supply, vault_a_balance, vault_b_balance)?, + }) +} + +/// Values used to initialize a pool-backed TWAP oracle price account. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OraclePriceAccountQuote { + /// Pool token A, used as the oracle base asset. + pub base_asset: AccountId, + /// Pool token B, used as the oracle quote asset. + pub quote_asset: AccountId, + /// Current pool spot price encoded as Q64.64. + pub initial_price_q64_64: u128, + /// Requested TWAP window duration in milliseconds. + pub window_duration: u64, +} + +/// Quotes values derived by `CreateOraclePriceAccount` from pool state. +pub fn create_oracle_price_account( + pool: &PoolDefinition, + window_duration: u64, +) -> Result { + if window_duration < u64::from(OBSERVATIONS_CAPACITY) { + return Err(QuoteError::new( + "oracle_window_too_short", + "Create oracle price account: window_duration must be >= OBSERVATIONS_CAPACITY so a matching PriceObservations account can exist and PublishPrice can update this price account", + )); + } + if pool.reserve_a == 0 { + return Err(QuoteError::new( + "reserve_a_zero", + "spot_price_q64_64: reserve_base must be non-zero", + )); + } + let initial_price_q64_64 = spot_price_q64_64(pool.reserve_a, pool.reserve_b); + if initial_price_q64_64 == 0 { + return Err(QuoteError::new( + "oracle_price_zero", + "Create oracle price account: pool spot price must be non-zero (zero is the no-price sentinel; pool reserve_b is zero or negligible relative to reserve_a)", + )); + } + + Ok(OraclePriceAccountQuote { + base_asset: pool.definition_token_a_id, + quote_asset: pool.definition_token_b_id, + initial_price_q64_64, + window_duration, + }) +} + +fn ensure_supported_fee_tier(fee_bps: u128) -> Result<(), QuoteError> { + if is_supported_fee_tier(fee_bps) { + Ok(()) + } else { + Err(QuoteError::new( + "unsupported_fee_tier", + "Fee tier must be one of 1, 5, 30, or 100 basis points", + )) + } +} + +fn ensure_vault_balances( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, + vault_a_message: &'static str, + vault_b_message: &'static str, +) -> Result<(), QuoteError> { + if vault_a_balance < pool.reserve_a { + return Err(QuoteError::new( + "vault_a_balance_below_reserve", + vault_a_message, + )); + } + if vault_b_balance < pool.reserve_b { + return Err(QuoteError::new( + "vault_b_balance_below_reserve", + vault_b_message, + )); + } + + Ok(()) +} + +fn validate_swap_pool( + pool: &PoolDefinition, + vault_a_balance: u128, + vault_b_balance: u128, +) -> Result<(), QuoteError> { + ensure_supported_fee_tier(pool.fees)?; + if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { + return Err(QuoteError::new( + "liquidity_supply_below_minimum", + "Pool liquidity supply is below minimum liquidity", + )); + } + ensure_vault_balances( + pool, + vault_a_balance, + vault_b_balance, + "Reserve for Token A exceeds vault balance", + "Reserve for Token B exceeds vault balance", + ) +} + +fn directional_reserves(pool: &PoolDefinition, direction: SwapDirection) -> (u128, u128) { + match direction { + SwapDirection::AToB => (pool.reserve_a, pool.reserve_b), + SwapDirection::BToA => (pool.reserve_b, pool.reserve_a), + } +} + +fn finish_swap_quote( + pool: &PoolDefinition, + direction: SwapDirection, + amount_in: u128, + effective_amount_in: u128, + amount_out: u128, +) -> Result { + let fee_amount = amount_in.checked_sub(effective_amount_in).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "gross input - effective input underflows", + ) + })?; + let (reserve_a, reserve_b) = match direction { + SwapDirection::AToB => ( + pool.reserve_a.checked_add(amount_in).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_a + deposit_a overflows u128", + ) + })?, + pool.reserve_b.checked_sub(amount_out).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_b + deposit_b - withdraw_b underflows", + ) + })?, + ), + SwapDirection::BToA => ( + pool.reserve_a.checked_sub(amount_out).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_a + deposit_a - withdraw_a underflows", + ) + })?, + pool.reserve_b.checked_add(amount_in).ok_or_else(|| { + QuoteError::new( + "arithmetic_overflow", + "reserve_b + deposit_b overflows u128", + ) + })?, + ), + }; + + Ok(SwapQuote { + direction, + amount_in, + effective_amount_in, + fee_amount, + amount_out, + pool: pool_update(pool.liquidity_pool_supply, reserve_a, reserve_b)?, + }) +} + +fn fee_multiplier(fee_bps: u128) -> Result { + FEE_BPS_DENOMINATOR + .checked_sub(fee_bps) + .ok_or_else(|| QuoteError::new("unsupported_fee_tier", "fee_bps exceeds fee denominator")) +} + +fn pool_update( + liquidity_pool_supply: u128, + reserve_a: u128, + reserve_b: u128, +) -> Result { + if reserve_a == 0 { + return Err(QuoteError::new( + "reserve_a_zero", + "spot_price_q64_64: reserve_base must be non-zero", + )); + } + + Ok(PoolUpdate { + liquidity_pool_supply, + reserve_a, + reserve_b, + spot_price_q64_64: spot_price_q64_64(reserve_a, reserve_b), + }) +} + +fn checked_floor( + left: u128, + right: u128, + denominator: u128, + overflow_message: &'static str, +) -> Result { + checked_mul_div_floor(left, right, denominator) + .ok_or_else(|| QuoteError::new("arithmetic_overflow", overflow_message)) +} + +fn checked_ceil( + left: u128, + right: u128, + denominator: u128, + overflow_message: &'static str, +) -> Result { + checked_mul_div_ceil(left, right, denominator) + .ok_or_else(|| QuoteError::new("arithmetic_overflow", overflow_message)) +} diff --git a/programs/amm/src/remove.rs b/programs/amm/src/remove.rs index 2d0bbf41..65ba5c53 100644 --- a/programs/amm/src/remove.rs +++ b/programs/amm/src/remove.rs @@ -1,9 +1,8 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, - compute_pool_pda_seed, compute_vault_pda_seed, mul_div_floor, spot_price_q64_64, AmmConfig, - PoolDefinition, MINIMUM_LIQUIDITY, + compute_config_pda, compute_liquidity_token_pda_seed, compute_pool_pda_seed, + compute_vault_pda_seed, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ @@ -12,6 +11,8 @@ use nssa_core::{ }; use twap_oracle_core::compute_current_tick_account_pda; +use crate::quote; + #[expect( clippy::too_many_arguments, reason = "instruction surface passes explicit pool, vault, and user accounts" @@ -49,12 +50,6 @@ pub fn remove_liquidity( // 1. Fetch Pool state let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("Remove liquidity: AMM Program expects a valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); - - assert!( - pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, - "Pool liquidity supply is below minimum liquidity" - ); assert_eq!( pool_def_data.liquidity_pool_id, pool_definition_lp.account_id, "LP definition mismatch" @@ -104,15 +99,6 @@ pub fn remove_liquidity( running_vault_a.is_authorized = true; running_vault_b.is_authorized = true; - assert!( - min_amount_to_remove_token_a != 0, - "Minimum withdraw amount must be nonzero" - ); - assert!( - min_amount_to_remove_token_b != 0, - "Minimum withdraw amount must be nonzero" - ); - // 2. Compute withdrawal amounts let user_holding_lp_data = token_core::TokenHolding::try_from(&user_holding_lp.account.data) .expect("Remove liquidity: AMM Program expects a valid Token Account for liquidity token"); @@ -126,79 +112,23 @@ pub fn remove_liquidity( ); }; - assert!( - user_lp_balance <= pool_def_data.liquidity_pool_supply, - "Invalid liquidity account provided" - ); assert_eq!( user_holding_lp_data.definition_id(), pool_def_data.liquidity_pool_id, "Invalid liquidity account provided" ); - // Honest flows should never reach the permanent lock through a valid remove instruction, but - // we still reject legacy or corrupted states that are already at the locked floor. - assert!( - pool_def_data.liquidity_pool_supply > MINIMUM_LIQUIDITY, - "Pool only contains locked liquidity" - ); - assert!( - remove_liquidity_amount <= user_lp_balance, - "Remove amount exceeds user LP balance" - ); - let unlocked_liquidity = pool_def_data - .liquidity_pool_supply - .checked_sub(MINIMUM_LIQUIDITY) - .expect("liquidity supply must be at least the locked minimum after validation"); - // The remove instruction never sees the LP lock account directly, so we must still refuse any - // request that would burn through the permanent floor even if ownership is already corrupted. - assert!( - remove_liquidity_amount <= unlocked_liquidity, - "Cannot remove locked minimum liquidity" - ); - - // floor(reserve * remove_amount / supply), products widened to U256. Supply exceeds - // MINIMUM_LIQUIDITY (asserted above), so the divisor is nonzero. - let withdraw_amount_a = mul_div_floor( - pool_def_data.reserve_a, + let liquidity_quote = quote::remove_liquidity( + &pool_def_data, + user_lp_balance, remove_liquidity_amount, - pool_def_data.liquidity_pool_supply, - ); - let withdraw_amount_b = mul_div_floor( - pool_def_data.reserve_b, - remove_liquidity_amount, - pool_def_data.liquidity_pool_supply, - ); - - // 3. Validate and slippage check - assert!( - withdraw_amount_a >= min_amount_to_remove_token_a, - "Insufficient minimal withdraw amount (Token A) provided for liquidity amount" - ); - assert!( - withdraw_amount_b >= min_amount_to_remove_token_b, - "Insufficient minimal withdraw amount (Token B) provided for liquidity amount" - ); - - // 4. Calculate LP to reduce cap by - let delta_lp: u128 = remove_liquidity_amount; + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + ) + .unwrap_or_else(|error| panic!("{error}")); // 5. Update pool account let mut pool_post = pool.account.clone(); - let pool_post_definition = PoolDefinition { - liquidity_pool_supply: pool_def_data - .liquidity_pool_supply - .checked_sub(delta_lp) - .expect("liquidity_pool_supply - delta_lp underflows"), - reserve_a: pool_def_data - .reserve_a - .checked_sub(withdraw_amount_a) - .expect("reserve_a - withdraw_amount_a underflows"), - reserve_b: pool_def_data - .reserve_b - .checked_sub(withdraw_amount_b) - .expect("reserve_b - withdraw_amount_b underflows"), - ..pool_def_data.clone() - }; + let pool_post_definition = liquidity_quote.pool.apply_to(&pool_def_data); pool_post.data = Data::from(&pool_post_definition); @@ -207,7 +137,7 @@ pub fn remove_liquidity( token_program_id, vec![running_vault_a, user_holding_a.clone()], &token_core::Instruction::Transfer { - amount_to_transfer: withdraw_amount_a, + amount_to_transfer: liquidity_quote.withdraw_amount_a, }, ) .with_pda_seeds(vec![compute_vault_pda_seed( @@ -219,7 +149,7 @@ pub fn remove_liquidity( token_program_id, vec![running_vault_b, user_holding_b.clone()], &token_core::Instruction::Transfer { - amount_to_transfer: withdraw_amount_b, + amount_to_transfer: liquidity_quote.withdraw_amount_b, }, ) .with_pda_seeds(vec![compute_vault_pda_seed( @@ -233,7 +163,7 @@ pub fn remove_liquidity( token_program_id, vec![pool_definition_lp_auth, user_holding_lp.clone()], &token_core::Instruction::Burn { - amount_to_burn: delta_lp, + amount_to_burn: liquidity_quote.liquidity_to_burn, }, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); @@ -241,10 +171,6 @@ pub fn remove_liquidity( // Refresh the pool's TWAP current tick from the post-removal spot price. The pool is already // owned by this program, so it is passed (in its post-removal state) as the authorized price // source. - let new_price = spot_price_q64_64( - pool_post_definition.reserve_a, - pool_post_definition.reserve_b, - ); let pool_price_source = AccountWithMetadata { account: pool_post.clone(), is_authorized: true, @@ -257,7 +183,9 @@ pub fn remove_liquidity( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + &twap_oracle_core::Instruction::UpdateCurrentTick { + price: liquidity_quote.pool.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( pool_def_data.definition_token_a_id, diff --git a/programs/amm/src/swap.rs b/programs/amm/src/swap.rs index 1f102529..5882ddd0 100644 --- a/programs/amm/src/swap.rs +++ b/programs/amm/src/swap.rs @@ -1,7 +1,5 @@ use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, mul_div_ceil, - mul_div_floor, read_vault_fungible_balances, spot_price_q64_64, AmmConfig, FEE_BPS_DENOMINATOR, - MINIMUM_LIQUIDITY, + compute_config_pda, compute_pool_pda_seed, read_vault_fungible_balances, AmmConfig, }; pub use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition}; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; @@ -11,20 +9,16 @@ use nssa_core::{ }; use twap_oracle_core::compute_current_tick_account_pda; -/// Validates swap setup: checks pool liquidity is ready, vaults match, and reserves are sufficient. +use crate::quote::{self, PoolUpdate, SwapDirection}; + +/// Decodes pool state, checks vault IDs, and reads vault balances for quote validation. fn validate_swap_setup( pool: &AccountWithMetadata, vault_a: &AccountWithMetadata, vault_b: &AccountWithMetadata, -) -> PoolDefinition { +) -> (PoolDefinition, u128, u128) { let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("AMM Program expects a valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); - - assert!( - pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, - "Pool liquidity supply is below minimum liquidity" - ); assert_eq!( vault_a.account_id, pool_def_data.vault_a_id, "Vault A was not provided" @@ -37,23 +31,14 @@ fn validate_swap_setup( let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Validate swap setup", vault_a, vault_b); - assert!( - vault_a_balance >= pool_def_data.reserve_a, - "Reserve for Token A exceeds vault balance" - ); - assert!( - vault_b_balance >= pool_def_data.reserve_b, - "Reserve for Token B exceeds vault balance" - ); - - pool_def_data + (pool_def_data, vault_a_balance, vault_b_balance) } /// Assembles the swap post-states (including the echoed current-tick and clock accounts) and the /// chained call that refreshes the pool's TWAP current tick from the post-swap spot price. #[expect( clippy::too_many_arguments, - reason = "post-state assembly keeps pool, vault, user, oracle, and delta state explicit" + reason = "post-state assembly keeps pool, vault, user, oracle, and quoted pool state explicit" )] #[expect( clippy::needless_pass_by_value, @@ -71,37 +56,16 @@ fn finalize_swap( user_holding_output: AccountWithMetadata, current_tick_account: AccountWithMetadata, clock: AccountWithMetadata, - deposit_a: u128, - withdraw_a: u128, - deposit_b: u128, - withdraw_b: u128, + pool_update: PoolUpdate, twap_oracle_program_id: ProgramId, ) -> (Vec, ChainedCall) { - let pool_post_definition = PoolDefinition { - reserve_a: pool_def_data - .reserve_a - .checked_add(deposit_a) - .expect("reserve_a + deposit_a overflows u128") - .checked_sub(withdraw_a) - .expect("reserve_a + deposit_a - withdraw_a underflows"), - reserve_b: pool_def_data - .reserve_b - .checked_add(deposit_b) - .expect("reserve_b + deposit_b overflows u128") - .checked_sub(withdraw_b) - .expect("reserve_b + deposit_b - withdraw_b underflows"), - ..pool_def_data - }; + let pool_post_definition = pool_update.apply_to(&pool_def_data); let mut pool_post = pool.account.clone(); pool_post.data = Data::from(&pool_post_definition); // Refresh the pool's TWAP current tick from the post-swap spot price. The pool is already owned // by this program, so it is passed (in its post-swap state) as the authorized price source. - let new_price = spot_price_q64_64( - pool_post_definition.reserve_a, - pool_post_definition.reserve_b, - ); let pool_price_source = AccountWithMetadata { account: pool_post.clone(), is_authorized: true, @@ -114,7 +78,9 @@ fn finalize_swap( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + &twap_oracle_core::Instruction::UpdateCurrentTick { + price: pool_update.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( pool_def_data.definition_token_a_id, @@ -153,7 +119,8 @@ pub fn swap_exact_input( min_amount_out: u128, amm_program_id: ProgramId, ) -> (Vec, Vec) { - let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b); + let (pool_def_data, vault_a_balance, vault_b_balance) = + validate_swap_setup(&pool, &vault_a, &vault_b); // The program IDs are taken from the config account, not trusted from a caller-supplied // account. Validating the config PDA is also the Program's initialization gate. @@ -181,12 +148,12 @@ pub fn swap_exact_input( let token_in_id = token_core::TokenHolding::try_from(&user_input_holding.account.data) .expect("Swap exact input: input holding must be a valid token holding") .definition_id(); - let (user_holding_a, user_holding_b) = if token_in_id == pool_def_data.definition_token_a_id { - (user_input_holding, user_output_holding) - } else if token_in_id == pool_def_data.definition_token_b_id { - (user_output_holding, user_input_holding) - } else { - panic!("Swap exact input: input holding token is not part of the pool"); + let direction = quote::swap_direction(&pool_def_data, token_in_id).unwrap_or_else(|_| { + panic!("Swap exact input: input holding token is not part of the pool") + }); + let (user_holding_a, user_holding_b) = match direction { + SwapDirection::AToB => (user_input_holding, user_output_holding), + SwapDirection::BToA => (user_output_holding, user_input_holding), }; assert_eq!( user_holding_a.account.program_owner, token_program_id, @@ -208,50 +175,43 @@ pub fn swap_exact_input( "Swap exact input: current tick Account ID does not match PDA" ); - let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) = - if token_in_id == pool_def_data.definition_token_a_id { - let (chained_calls, deposit_a, withdraw_b) = swap_logic( - user_holding_a.clone(), - vault_a.clone(), - vault_b.clone(), - user_holding_b.clone(), - swap_amount_in, - min_amount_out, - pool_def_data.fees, - pool_def_data.reserve_a, - pool_def_data.reserve_b, - pool.account_id, - ); - - (chained_calls, [deposit_a, 0], [0, withdraw_b]) - } else if token_in_id == pool_def_data.definition_token_b_id { - let (chained_calls, deposit_b, withdraw_a) = swap_logic( - user_holding_b.clone(), - vault_b.clone(), - vault_a.clone(), - user_holding_a.clone(), - swap_amount_in, - min_amount_out, - pool_def_data.fees, - pool_def_data.reserve_b, - pool_def_data.reserve_a, - pool.account_id, - ); - - (chained_calls, [0, withdraw_a], [deposit_b, 0]) - } else { - panic!("AccountId is not a token type for the pool"); - }; + let swap_quote = quote::swap_exact_input( + &pool_def_data, + vault_a_balance, + vault_b_balance, + direction, + swap_amount_in, + min_amount_out, + ) + .unwrap_or_else(|error| panic!("{error}")); + let chained_calls = match direction { + SwapDirection::AToB => swap_chained_calls( + user_holding_a.clone(), + vault_a.clone(), + vault_b.clone(), + user_holding_b.clone(), + swap_quote.amount_in, + swap_quote.amount_out, + pool.account_id, + ), + SwapDirection::BToA => swap_chained_calls( + user_holding_b.clone(), + vault_b.clone(), + vault_a.clone(), + user_holding_a.clone(), + swap_quote.amount_in, + swap_quote.amount_out, + pool.account_id, + ), + }; // Echo the two user holdings in the guest's declared slot order (input, then output) so the // framework matches each post-state to the right account. The a/b mapping above only drives the // reserve/vault bookkeeping; post-states are matched to accounts positionally. - let (user_holding_input, user_holding_output) = - if token_in_id == pool_def_data.definition_token_a_id { - (user_holding_a, user_holding_b) - } else { - (user_holding_b, user_holding_a) - }; + let (user_holding_input, user_holding_output) = match direction { + SwapDirection::AToB => (user_holding_a, user_holding_b), + SwapDirection::BToA => (user_holding_b, user_holding_a), + }; let (post_states, update_tick_call) = finalize_swap( config, pool, @@ -262,10 +222,7 @@ pub fn swap_exact_input( user_holding_output, current_tick_account, clock, - deposit_a, - withdraw_a, - deposit_b, - withdraw_b, + swap_quote.pool, twap_oracle_program_id, ); @@ -275,53 +232,15 @@ pub fn swap_exact_input( (post_states, chained_calls) } -#[expect( - clippy::too_many_arguments, - reason = "swap calculation keeps account context and pricing parameters explicit" -)] -fn swap_logic( +fn swap_chained_calls( user_deposit: AccountWithMetadata, vault_deposit: AccountWithMetadata, vault_withdraw: AccountWithMetadata, user_withdraw: AccountWithMetadata, - swap_amount_in: u128, - min_amount_out: u128, - fee_bps: u128, - reserve_deposit_vault_amount: u128, - reserve_withdraw_vault_amount: u128, + amount_in: u128, + amount_out: u128, pool_id: AccountId, -) -> (Vec, u128, u128) { - let fee_multiplier = FEE_BPS_DENOMINATOR - .checked_sub(fee_bps) - .expect("fee_bps exceeds fee denominator"); - // floor(swap_amount_in * fee_multiplier / FEE_BPS_DENOMINATOR), product widened to U256. - let effective_amount_in = mul_div_floor(swap_amount_in, fee_multiplier, FEE_BPS_DENOMINATOR); - assert!( - effective_amount_in != 0, - "Effective swap amount should be nonzero" - ); - // Compute the withdraw amount using the fee-adjusted input for pricing. - // 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 separate vault balance surplus over `reserve_*`. - // The denominator sum stays u128 (overflows only near u128::MAX, an unstorable reserve); - // only the `reserve * effective` product is widened to U256. - let reserve_plus_effective = reserve_deposit_vault_amount - .checked_add(effective_amount_in) - .expect("reserve + effective_amount_in overflows u128"); - let withdraw_amount = mul_div_floor( - reserve_withdraw_vault_amount, - effective_amount_in, - reserve_plus_effective, - ); - - // Slippage check - assert!( - min_amount_out <= withdraw_amount, - "Withdraw amount is less than minimal amount out" - ); - assert!(withdraw_amount != 0, "Withdraw amount should be nonzero"); - +) -> Vec { let token_program_id = user_deposit.account.program_owner; let mut chained_calls = Vec::new(); @@ -329,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, }, )); @@ -348,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( @@ -375,7 +294,8 @@ pub fn swap_exact_output( max_amount_in: u128, amm_program_id: ProgramId, ) -> (Vec, Vec) { - let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b); + let (pool_def_data, vault_a_balance, vault_b_balance) = + validate_swap_setup(&pool, &vault_a, &vault_b); // The program IDs are taken from the config account, not trusted from a caller-supplied // account. Validating the config PDA is also the Program's initialization gate. @@ -403,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, @@ -430,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, @@ -484,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, ); @@ -496,93 +406,3 @@ pub fn swap_exact_output( (post_states, chained_calls) } - -#[expect( - clippy::too_many_arguments, - reason = "swap calculation keeps account context and pricing parameters explicit" -)] -fn exact_output_swap_logic( - user_deposit: AccountWithMetadata, - vault_deposit: AccountWithMetadata, - vault_withdraw: AccountWithMetadata, - user_withdraw: AccountWithMetadata, - exact_amount_out: u128, - max_amount_in: u128, - reserve_deposit_vault_amount: u128, - reserve_withdraw_vault_amount: u128, - fee_bps: u128, - pool_id: AccountId, -) -> (Vec, u128, u128) { - // Guard: exact_amount_out must be nonzero - assert_ne!(exact_amount_out, 0, "Exact amount out must be nonzero"); - - // Guard: exact_amount_out must be less than reserve_withdraw_vault_amount - assert!( - exact_amount_out < reserve_withdraw_vault_amount, - "Exact amount out exceeds reserve" - ); - - // Compute the minimum effective input required to achieve exact_amount_out - // using the same floor-rounded fee application as swap_exact_input. - // - // Solve constant product for effective_in (fee already removed): - // effective_in >= ceil(reserve_in * amount_out / (reserve_out - amount_out)) - // ceil(reserve_in * amount_out / (reserve_out - amount_out)). The `reserve_in * amount_out` - // product is widened to U256; the denominator is a subtraction that stays u128. - let effective_in_denominator = reserve_withdraw_vault_amount - .checked_sub(exact_amount_out) - .expect("reserve_out - amount_out underflows"); - let effective_in_min = mul_div_ceil( - reserve_deposit_vault_amount, - exact_amount_out, - effective_in_denominator, - ); - - // Lift back to gross input so that - // floor(gross_in * (FEE_DENOM - fee) / FEE_DENOM) >= effective_in_min - // ceil(effective_in_min * FEE_BPS_DENOMINATOR / fee_multiplier), product widened to U256. - let fee_multiplier = FEE_BPS_DENOMINATOR - .checked_sub(fee_bps) - .expect("fee_bps exceeds fee denominator"); - let deposit_amount = mul_div_ceil(effective_in_min, FEE_BPS_DENOMINATOR, fee_multiplier); - - // Slippage check - assert!( - deposit_amount <= max_amount_in, - "Required input exceeds maximum amount in" - ); - - let token_program_id = user_deposit.account.program_owner; - - let mut chained_calls = Vec::new(); - chained_calls.push(ChainedCall::new( - token_program_id, - vec![user_deposit, vault_deposit], - &token_core::Instruction::Transfer { - amount_to_transfer: deposit_amount, - }, - )); - - let mut vault_withdraw = vault_withdraw; - vault_withdraw.is_authorized = true; - - let pda_seed = compute_vault_pda_seed( - pool_id, - token_core::TokenHolding::try_from(&vault_withdraw.account.data) - .expect("Exact Output Swap Logic: AMM Program expects valid token data") - .definition_id(), - ); - - chained_calls.push( - ChainedCall::new( - token_program_id, - vec![vault_withdraw, user_withdraw], - &token_core::Instruction::Transfer { - amount_to_transfer: exact_amount_out, - }, - ) - .with_pda_seeds(vec![pda_seed]), - ); - - (chained_calls, deposit_amount, exact_amount_out) -} diff --git a/programs/amm/src/sync.rs b/programs/amm/src/sync.rs index 250bfc89..b7d99a71 100644 --- a/programs/amm/src/sync.rs +++ b/programs/amm/src/sync.rs @@ -1,6 +1,6 @@ use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, - read_vault_fungible_balances, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, + compute_config_pda, compute_pool_pda_seed, read_vault_fungible_balances, AmmConfig, + PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ @@ -9,6 +9,8 @@ use nssa_core::{ }; use twap_oracle_core::compute_current_tick_account_pda; +use crate::quote; + pub fn sync_reserves( config: AccountWithMetadata, pool: AccountWithMetadata, @@ -20,7 +22,6 @@ pub fn sync_reserves( ) -> (Vec, Vec) { let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("Sync reserves: AMM Program expects a valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); // The TWAP oracle program ID is taken from the config account. Validating the config PDA is // also the Program's initialization gate. @@ -33,10 +34,6 @@ pub fn sync_reserves( .expect("Sync reserves: AMM Program must be initialized before use") .twap_oracle_program_id; - assert!( - pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, - "Pool liquidity supply is below minimum liquidity" - ); assert_eq!( vault_a.account_id, pool_def_data.vault_a_id, "Vault A was not provided" @@ -59,26 +56,14 @@ pub fn sync_reserves( let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Sync reserves", &vault_a, &vault_b); - assert!( - vault_a_balance >= pool_def_data.reserve_a, - "Sync reserves: vault A balance is less than its reserve" - ); - assert!( - vault_b_balance >= pool_def_data.reserve_b, - "Sync reserves: vault B balance is less than its reserve" - ); - - let pool_post_definition = PoolDefinition { - reserve_a: vault_a_balance, - reserve_b: vault_b_balance, - ..pool_def_data - }; + let sync_quote = quote::sync_reserves(&pool_def_data, vault_a_balance, vault_b_balance) + .unwrap_or_else(|error| panic!("{error}")); + let pool_post_definition = sync_quote.pool.apply_to(&pool_def_data); let mut pool_post = pool.account.clone(); pool_post.data = Data::from(&pool_post_definition); // Refresh the pool's TWAP current tick from the synced spot price. The pool is already owned by // this program, so it is passed (in its synced state) as the authorized price source. - let new_price = spot_price_q64_64(vault_a_balance, vault_b_balance); let pool_price_source = AccountWithMetadata { account: pool_post.clone(), is_authorized: true, @@ -91,7 +76,9 @@ pub fn sync_reserves( pool_price_source, clock.clone(), ], - &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, + &twap_oracle_core::Instruction::UpdateCurrentTick { + price: sync_quote.pool.spot_price_q64_64, + }, ) .with_pda_seeds(vec![compute_pool_pda_seed( pool_def_data.definition_token_a_id, diff --git a/programs/amm/tests/quote_api.rs b/programs/amm/tests/quote_api.rs new file mode 100644 index 00000000..c8879d7a --- /dev/null +++ b/programs/amm/tests/quote_api.rs @@ -0,0 +1,241 @@ +use amm_program::{ + core::{spot_price_q64_64, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY}, + quote::{ + self, AddLiquidityQuote, CreatePoolQuote, PairOrder, PoolUpdate, RemoveLiquidityQuote, + SwapDirection, SwapQuote, SyncReservesQuote, + }, +}; +use nssa_core::account::AccountId; +use twap_oracle_core::OBSERVATIONS_CAPACITY; + +fn token_a_id() -> AccountId { + AccountId::new([1; 32]) +} + +fn token_b_id() -> AccountId { + AccountId::new([2; 32]) +} + +fn pool() -> PoolDefinition { + PoolDefinition { + definition_token_a_id: token_a_id(), + definition_token_b_id: token_b_id(), + vault_a_id: AccountId::new([3; 32]), + vault_b_id: AccountId::new([4; 32]), + liquidity_pool_id: AccountId::new([5; 32]), + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + } +} + +#[test] +fn create_pool_quotes_locked_and_user_liquidity() { + assert_eq!( + quote::create_pool(4_000, 9_000, FEE_TIER_BPS_30), + Ok(CreatePoolQuote { + pool: PoolUpdate { + liquidity_pool_supply: 6_000, + reserve_a: 4_000, + reserve_b: 9_000, + spot_price_q64_64: spot_price_q64_64(4_000, 9_000), + }, + locked_liquidity: MINIMUM_LIQUIDITY, + user_liquidity: 5_000, + }) + ); +} + +#[test] +fn add_liquidity_quotes_program_rounding_and_post_pool() { + assert_eq!( + quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 399), + Ok(AddLiquidityQuote { + actual_amount_a: 200, + actual_amount_b: 100, + liquidity_to_mint: 400, + pool: PoolUpdate { + liquidity_pool_supply: 2_400, + reserve_a: 1_200, + reserve_b: 600, + spot_price_q64_64: spot_price_q64_64(1_200, 600), + }, + }) + ); +} + +#[test] +fn preview_helpers_return_amounts_before_client_slippage_policy() { + let add = quote::preview_add_liquidity(&pool(), 1_000, 500, 400, 100) + .expect("valid add should preview"); + let remove = + quote::preview_remove_liquidity(&pool(), 1_000, 500).expect("valid removal should preview"); + let exact_input = + quote::preview_swap_exact_input(&pool(), 1_000, 500, SwapDirection::AToB, 100) + .expect("valid exact-input trade should preview"); + let exact_output = + quote::preview_swap_exact_output(&pool(), 1_000, 500, SwapDirection::AToB, 45) + .expect("valid exact-output trade should preview"); + + assert_eq!(add.liquidity_to_mint, 400); + assert_eq!(remove.withdraw_amount_a, 250); + assert_eq!(exact_input.amount_out, 45); + assert_eq!(exact_output.amount_in, 100); +} + +#[test] +fn remove_liquidity_quotes_program_rounding_and_post_pool() { + assert_eq!( + quote::remove_liquidity(&pool(), 1_000, 500, 250, 125), + Ok(RemoveLiquidityQuote { + withdraw_amount_a: 250, + withdraw_amount_b: 125, + liquidity_to_burn: 500, + pool: PoolUpdate { + liquidity_pool_supply: 1_500, + reserve_a: 750, + reserve_b: 375, + spot_price_q64_64: spot_price_q64_64(750, 375), + }, + }) + ); +} + +#[test] +fn exact_input_and_output_quotes_share_the_same_boundary() { + let expected = SwapQuote { + direction: SwapDirection::AToB, + amount_in: 100, + effective_amount_in: 99, + fee_amount: 1, + amount_out: 45, + pool: PoolUpdate { + liquidity_pool_supply: 2_000, + reserve_a: 1_100, + reserve_b: 455, + spot_price_q64_64: spot_price_q64_64(1_100, 455), + }, + }; + + assert_eq!( + quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::AToB, 100, 45), + Ok(expected) + ); + assert_eq!( + quote::swap_exact_output(&pool(), 1_000, 500, SwapDirection::AToB, 45, 100), + Ok(expected) + ); +} + +#[test] +fn reverse_swap_quote_keeps_pool_updates_in_stored_order() { + assert_eq!( + quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::BToA, 100, 165), + Ok(SwapQuote { + direction: SwapDirection::BToA, + amount_in: 100, + effective_amount_in: 99, + fee_amount: 1, + amount_out: 165, + pool: PoolUpdate { + liquidity_pool_supply: 2_000, + reserve_a: 835, + reserve_b: 600, + spot_price_q64_64: spot_price_q64_64(835, 600), + }, + }) + ); +} + +#[test] +fn sync_reserves_reports_donations_and_post_pool() { + assert_eq!( + quote::sync_reserves(&pool(), 1_100, 550), + Ok(SyncReservesQuote { + donated_amount_a: 100, + donated_amount_b: 50, + pool: PoolUpdate { + liquidity_pool_supply: 2_000, + reserve_a: 1_100, + reserve_b: 550, + spot_price_q64_64: spot_price_q64_64(1_100, 550), + }, + }) + ); +} + +#[test] +fn pair_and_swap_direction_follow_stored_pool_order() { + let pool = pool(); + + assert_eq!( + quote::pair_order(&pool, token_a_id(), token_b_id()), + Ok(PairOrder::Stored) + ); + assert_eq!( + quote::pair_order(&pool, token_b_id(), token_a_id()), + Ok(PairOrder::Reversed) + ); + assert_eq!( + quote::swap_direction(&pool, token_a_id()), + Ok(SwapDirection::AToB) + ); + assert_eq!( + quote::swap_direction(&pool, token_b_id()), + Ok(SwapDirection::BToA) + ); +} + +#[test] +fn oracle_price_quote_uses_pool_assets_and_spot_price() { + let window_duration = u64::from(OBSERVATIONS_CAPACITY); + let result = quote::create_oracle_price_account(&pool(), window_duration) + .expect("valid pool and window should quote"); + + assert_eq!(result.base_asset, token_a_id()); + assert_eq!(result.quote_asset, token_b_id()); + assert_eq!(result.initial_price_q64_64, spot_price_q64_64(1_000, 500)); + assert_eq!(result.window_duration, window_duration); +} + +#[test] +fn quote_errors_expose_stable_machine_codes() { + let error = quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 401) + .expect_err("minimum above minted liquidity must fail"); + + assert_eq!(error.code(), "minted_liquidity_below_minimum"); + assert_eq!( + error.message(), + "Payable LP is less than provided minimum LP amount" + ); +} + +#[test] +fn exact_quotes_apply_instruction_slippage_guards() { + let add = quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 401) + .expect_err("minimum LP above quote must fail"); + let remove = quote::remove_liquidity(&pool(), 1_000, 500, 251, 125) + .expect_err("minimum token A above quote must fail"); + let exact_input = quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::AToB, 100, 46) + .expect_err("minimum output above quote must fail"); + let exact_output = quote::swap_exact_output(&pool(), 1_000, 500, SwapDirection::AToB, 45, 99) + .expect_err("maximum input below quote must fail"); + + assert_eq!(add.code(), "minted_liquidity_below_minimum"); + assert_eq!(remove.code(), "withdrawal_a_below_minimum"); + assert_eq!(exact_input.code(), "swap_output_below_minimum"); + assert_eq!(exact_output.code(), "required_input_exceeds_maximum"); +} + +#[test] +fn arithmetic_overflow_is_returned_instead_of_panicking() { + let mut extreme_pool = pool(); + extreme_pool.reserve_a = u128::MAX; + extreme_pool.reserve_b = 1; + + let error = quote::add_liquidity(&extreme_pool, u128::MAX, 1, u128::MAX, u128::MAX, 1) + .expect_err("unrepresentable ideal amount must fail"); + + assert_eq!(error.code(), "arithmetic_overflow"); +} From 3fa48ef17ed787803294110592e749379b0ec4f9 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 13:46:54 -0300 Subject: [PATCH 2/8] feat(amm): add reusable client APIs Add validated shared quote orchestration, canonical planners for every guest instruction, and exact RISC Zero serialization. Expose integer-only slippage preparation and lossless JSON/C adapters without runtime deployment identity checks. --- Cargo.lock | 15 + Cargo.toml | 2 + programs/amm/client/Cargo.toml | 21 + programs/amm/client/README.md | 71 + programs/amm/client/build.rs | 17 + programs/amm/client/docs/wire-api.md | 185 +++ programs/amm/client/include/amm_client.h | 46 + programs/amm/client/src/error.rs | 126 ++ programs/amm/client/src/ffi.rs | 172 +++ programs/amm/client/src/lib.rs | 26 + programs/amm/client/src/plan.rs | 904 +++++++++++ programs/amm/client/src/quote.rs | 698 +++++++++ programs/amm/client/src/slippage.rs | 267 ++++ programs/amm/client/src/wire.rs | 1350 +++++++++++++++++ programs/amm/client/tests/ffi_contract.rs | 356 +++++ programs/amm/client/tests/plan_contract.rs | 636 ++++++++ programs/amm/client/tests/quote_contract.rs | 647 ++++++++ .../amm/client/tests/slippage_contract.rs | 91 ++ .../amm/client/tests/wire_prepare_contract.rs | 274 ++++ programs/amm/core/src/lib.rs | 2 +- programs/amm/src/quote.rs | 267 +++- programs/amm/tests/quote_api.rs | 284 ++-- 22 files changed, 6300 insertions(+), 157 deletions(-) create mode 100644 programs/amm/client/Cargo.toml create mode 100644 programs/amm/client/README.md create mode 100644 programs/amm/client/build.rs create mode 100644 programs/amm/client/docs/wire-api.md create mode 100644 programs/amm/client/include/amm_client.h create mode 100644 programs/amm/client/src/error.rs create mode 100644 programs/amm/client/src/ffi.rs create mode 100644 programs/amm/client/src/lib.rs create mode 100644 programs/amm/client/src/plan.rs create mode 100644 programs/amm/client/src/quote.rs create mode 100644 programs/amm/client/src/slippage.rs create mode 100644 programs/amm/client/src/wire.rs create mode 100644 programs/amm/client/tests/ffi_contract.rs create mode 100644 programs/amm/client/tests/plan_contract.rs create mode 100644 programs/amm/client/tests/quote_contract.rs create mode 100644 programs/amm/client/tests/slippage_contract.rs create mode 100644 programs/amm/client/tests/wire_prepare_contract.rs diff --git a/Cargo.lock b/Cargo.lock index 7c3a1403..76b3b1e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,21 @@ dependencies = [ "risc0-zkvm", ] +[[package]] +name = "amm_client" +version = "0.1.0" +dependencies = [ + "amm_core", + "amm_program", + "clock_core", + "lee_core", + "risc0-zkvm", + "serde", + "serde_json", + "token_core", + "twap_oracle_core", +] + [[package]] name = "amm_core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c508ac99..28bbb566 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "programs/token/methods", "programs/amm/core", "programs/amm", + "programs/amm/client", "programs/amm/methods", "programs/ata/core", "programs/ata", @@ -38,6 +39,7 @@ token_core = { path = "programs/token/core" } token_program = { path = "programs/token" } amm_core = { path = "programs/amm/core" } amm_program = { path = "programs/amm" } +amm_client = { path = "programs/amm/client" } ata_core = { path = "programs/ata/core" } ata_program = { path = "programs/ata" } twap_oracle_core = { path = "programs/twap_oracle/core" } diff --git a/programs/amm/client/Cargo.toml b/programs/amm/client/Cargo.toml new file mode 100644 index 00000000..0c8097ae --- /dev/null +++ b/programs/amm/client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "amm_client" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true + +[dependencies] +amm_core = { path = "../core" } +amm_program = { path = ".." } +clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" } +nssa_core = { workspace = true } +risc0-zkvm = { version = "=3.0.5", default-features = false } +serde = { workspace = true } +serde_json = { workspace = true } +token_core = { workspace = true } +twap_oracle_core = { path = "../../twap_oracle/core" } diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md new file mode 100644 index 00000000..ab58b8a4 --- /dev/null +++ b/programs/amm/client/README.md @@ -0,0 +1,71 @@ +# AMM client + +`amm_client` is the stateless host boundary for the AMM program. It reuses +`amm_program::quote` for economic calculations, builds the actual +`amm_core::Instruction` variants, derives protocol accounts through core PDA helpers, and encodes +instructions with the RISC Zero Serde codec consumed by the guest. + +The crate does not fetch accounts, manage keys, sign, or submit transactions. Those remain host +adapter responsibilities. + +## Rust API + +- `quote` validates fetched config, pool, vault, token-definition, LP-definition, and user-holding + snapshots before delegating calculations to `amm_program::quote`. +- `slippage` converts validated quotes into integer-only instruction guards. Minimum guards round + down, maximum guards round up, and checked overflow returns a typed error. +- `plan` covers all ten guest instructions and returns the canonical instruction plus ordered + account roles and writable, signer, and init flags. +- `TransactionPlan::instruction_data` serializes its `amm_core::Instruction` with + `risc0_zkvm::serde::to_vec`. +- `wire` exposes lossless JSON adapters for non-Rust hosts. + +Planner coverage: + +| Guest instruction | Planner | +|---|---| +| `Initialize` | `plan_initialize` | +| `UpdateConfig` | `plan_update_config` | +| `CreatePriceObservations` | `plan_create_price_observations` | +| `CreateOraclePriceAccount` | `plan_create_oracle_price_account` | +| `NewDefinition` | `plan_create_pool` | +| `AddLiquidity` | `plan_add_liquidity` | +| `RemoveLiquidity` | `plan_remove_liquidity` | +| `SwapExactInput` | `plan_swap_exact_input` | +| `SwapExactOutput` | `plan_swap_exact_output` | +| `SyncReserves` | `plan_sync_reserves` | + +Quote coverage includes protocol constants, pair ordering, pool creation, preview and exact +add/remove liquidity, preview and exact-input/output swaps, reserve synchronization, and +oracle-price initialization. `prepare_create_pool`, `prepare_add_liquidity`, +`prepare_remove_liquidity`, `prepare_swap_exact_input`, and `prepare_swap_exact_output` return a +quote plus the exact amount fields to pass to the corresponding planner. Consumers choose a +slippage tolerance in basis points but do not calculate chain guards. Prepared add-liquidity maxima +use the quote's actual deposits, so execution cannot spend above the displayed/current quote even +when the caller supplied a lopsided pair of caps. + +## Compatibility assumption + +The client and deployed AMM are expected to be built from the corresponding source version. The +client performs no runtime ImageID, release-version, or program allowlist check. The supplied AMM +program ID is used for transaction targeting and canonical PDA derivation. Snapshot owner, account +relationship, and PDA checks remain normal protocol validation. + +## C and JSON boundary + +The built library exports: + +```c +char *amm_client_plan(const char *request_json); +char *amm_client_quote(const char *request_json); +void amm_client_free(char *value); +``` + +Every call returns an owned JSON envelope. Release it exactly once with `amm_client_free`; passing +`NULL` to the free function is allowed. See [`include/amm_client.h`](include/amm_client.h) and +[`docs/wire-api.md`](docs/wire-api.md) for the complete transport contract. + +Raw `u128` and `u64` values cross JSON as decimal strings. Account IDs use their canonical base58 +display form, program IDs use eight JSON `u32` words, account data uses hexadecimal, and encoded +instruction words remain JSON `u32` numbers. No JavaScript `Number` conversion is required for +chain amounts or deadlines. diff --git a/programs/amm/client/build.rs b/programs/amm/client/build.rs new file mode 100644 index 00000000..0f5c2dd7 --- /dev/null +++ b/programs/amm/client/build.rs @@ -0,0 +1,17 @@ +use std::env; + +fn main() { + let Ok(target_os) = env::var("CARGO_CFG_TARGET_OS") else { + return; + }; + + // RISC Zero's host-side serde dependency contains guest syscall shims with exported C names. + // They are implementation details of this cdylib and would otherwise leak beside the three + // supported amm_client_* entry points. + if matches!( + target_os.as_str(), + "android" | "dragonfly" | "freebsd" | "linux" | "netbsd" | "openbsd" + ) { + println!("cargo:rustc-cdylib-link-arg=-Wl,--exclude-libs,ALL"); + } +} diff --git a/programs/amm/client/docs/wire-api.md b/programs/amm/client/docs/wire-api.md new file mode 100644 index 00000000..46b9e379 --- /dev/null +++ b/programs/amm/client/docs/wire-api.md @@ -0,0 +1,185 @@ +# AMM client JSON wire API + +The C ABI accepts one tagged JSON object and returns one envelope: + +```json +{"ok":true,"value":{}} +``` + +```json +{"ok":false,"error":{"code":"invalid_request","message":"..."}} +``` + +All `u128` amounts, reserves, supplies, fees, nonces, and balances are unsigned decimal strings. +All `u64` windows and deadlines are also decimal strings. Program IDs are arrays of eight `u32` +words. Account IDs are base58 strings. Account `data` is an even-length hexadecimal string. + +## Shared inputs + +Plan context: + +```json +{ + "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "tokenProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "twapOracleProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "authority": "base58-account-id" +} +``` + +Decoded pool input used by existing-pool planners: + +```json +{ + "poolId": "base58-account-id", + "definitionTokenAId": "base58-account-id", + "definitionTokenBId": "base58-account-id", + "vaultAId": "base58-account-id", + "vaultBId": "base58-account-id", + "liquidityPoolId": "base58-account-id", + "liquidityPoolSupply": "2000", + "reserveA": "1000", + "reserveB": "500", + "fees": "30" +} +``` + +Fetched account snapshot used by quotes: + +```json +{ + "id": "base58-account-id", + "programOwner": [0, 0, 0, 0, 0, 0, 0, 0], + "balance": "0", + "nonce": "0", + "data": "00ff" +} +``` + +Existing-pool quote operations include these top-level state fields: + +```json +{ + "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "config": { "...": "account snapshot" }, + "snapshot": { + "pool": { "...": "account snapshot" }, + "tokenADefinition": { "...": "account snapshot" }, + "tokenBDefinition": { "...": "account snapshot" }, + "vaultA": { "...": "account snapshot" }, + "vaultB": { "...": "account snapshot" }, + "liquidityDefinition": { "...": "account snapshot" } + } +} +``` + +## Plan operations + +Send requests to `amm_client_plan` or `wire::plan_json`. + +| `operation` | Additional fields | +|---|---| +| `initialize` | `ammProgramId`, `tokenProgramId`, `twapOracleProgramId`, `authority` | +| `update_config` | `context`, optional `tokenProgramId`, optional `twapOracleProgramId`, optional `newAuthority` | +| `create_price_observations` | `context`, `poolId`, `windowDuration` | +| `create_oracle_price_account` | `context`, `poolId`, `windowDuration` | +| `create_pool` | `context`, `tokenADefinitionId`, `tokenBDefinitionId`, `userHoldingA`, `userHoldingB`, `userHoldingLp`, `tokenAAmount`, `tokenBAmount`, `fees`, `deadline` | +| `add_liquidity` | `context`, `pool`, `userHoldingA`, `userHoldingB`, `userHoldingLp`, `minAmountLiquidity`, `maxAmountToAddTokenA`, `maxAmountToAddTokenB`, `deadline` | +| `remove_liquidity` | `context`, `pool`, `userHoldingA`, `userHoldingB`, `userHoldingLp`, `removeLiquidityAmount`, `minAmountToRemoveTokenA`, `minAmountToRemoveTokenB`, `deadline` | +| `swap_exact_input` | `context`, `pool`, `userInputHolding`, `userOutputHolding`, `swapAmountIn`, `minAmountOut`, `deadline` | +| `swap_exact_output` | `context`, `pool`, `userInputHolding`, `userOutputHolding`, `exactAmountOut`, `maxAmountIn`, `deadline` | +| `sync_reserves` | `context`, `pool` | + +A successful plan value contains the following fields (`instructionWords` is abbreviated here): + +```json +{ + "instruction": "add_liquidity", + "programId": [0, 0, 0, 0, 0, 0, 0, 0], + "accounts": [ + { + "id": "base58-account-id", + "role": "config", + "writable": false, + "signer": false, + "init": false + } + ], + "instructionWords": [5] +} +``` + +The real `instructionWords` array contains the complete encoding produced directly from the +canonical `amm_core::Instruction` with RISC Zero Serde. Account rows follow guest/IDL order. + +## Quote operations + +Send requests to `amm_client_quote` or `wire::quote_json`. Except `protocol_constants`, +`create_pool`, and `prepare_create_pool`, every operation below also includes the existing-pool +quote state described above. + +| `operation` | Additional fields | +|---|---| +| `protocol_constants` | none; returns decimal-string `minimumLiquidity`, `feeBpsDenominator`, `slippageBpsDenominator`, and `supportedFeeTiers` | +| `pair_order` | `firstTokenDefinitionId`, `secondTokenDefinitionId` | +| `create_pool` | `ammProgramId`, `config`, `tokenADefinition`, `tokenBDefinition`, `tokenAAmount`, `tokenBAmount`, `feeBps` | +| `prepare_create_pool` | same fields as `create_pool`; returns quote plus `NewDefinition` instruction arguments | +| `preview_add_liquidity` | `maxAmountA`, `maxAmountB` | +| `prepare_add_liquidity` | `maxAmountA`, `maxAmountB`, `slippageBps` | +| `add_liquidity` | `maxAmountA`, `maxAmountB`, `minimumLiquidity` | +| `preview_remove_liquidity` | `userLiquidityHolding`, `removeLiquidityAmount` | +| `prepare_remove_liquidity` | `userLiquidityHolding`, `removeLiquidityAmount`, `slippageBps` | +| `remove_liquidity` | `userLiquidityHolding`, `removeLiquidityAmount`, `minimumAmountA`, `minimumAmountB` | +| `preview_swap_exact_input` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `amountIn` | +| `prepare_swap_exact_input` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `amountIn`, `slippageBps` | +| `swap_exact_input` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `amountIn`, `minimumAmountOut` | +| `preview_swap_exact_output` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `exactAmountOut` | +| `prepare_swap_exact_output` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `exactAmountOut`, `slippageBps` | +| `swap_exact_output` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `exactAmountOut`, `maximumAmountIn` | +| `sync_reserves` | no additional fields | +| `create_oracle_price_account` | `windowDuration` | + +Quote values use these result shapes: + +- pool creation: `pool`, `lockedLiquidity`, `userLiquidity`; +- add liquidity: `actualAmountA`, `actualAmountB`, `liquidityToMint`, `pool`; +- remove liquidity: `withdrawAmountA`, `withdrawAmountB`, `liquidityToBurn`, `pool`; +- swaps: `direction`, `amountIn`, `effectiveAmountIn`, `feeAmount`, `amountOut`, `pool`; +- reserve sync: `donatedAmountA`, `donatedAmountB`, `pool`; +- oracle price: `baseAsset`, `quoteAsset`, `initialPriceQ64_64`, `windowDuration`; and +- pair order: `order` (`stored` or `reversed`). + +A `pool` result contains decimal-string `liquidityPoolSupply`, `reserveA`, `reserveB`, and +`spotPriceQ64_64` fields. + +## Prepared instruction arguments + +The five `prepare_*` operations return the economic result under `quote` and decimal-string chain +arguments under `instructionArgs`. Those fields map directly to the matching plan operation: + +- `prepare_create_pool`: `tokenAAmount`, `tokenBAmount`, `fees`; +- `prepare_add_liquidity`: `minAmountLiquidity`, `maxAmountToAddTokenA`, + `maxAmountToAddTokenB`; +- `prepare_remove_liquidity`: `removeLiquidityAmount`, `minAmountToRemoveTokenA`, + `minAmountToRemoveTokenB`; +- `prepare_swap_exact_input`: `swapAmountIn`, `minAmountOut`; and +- `prepare_swap_exact_output`: `exactAmountOut`, `maxAmountIn`. + +`slippageBps` accepts `0` through `slippageBpsDenominator` (`10,000`) as an unsigned decimal +string. Minimum guards use integer floor rounding and stay at least one raw unit for positive +quotes. Maximum guards use integer ceil rounding. A maximum above `u128` returns +`slippage_bound_overflow`; an out-of-range tolerance returns `slippage_tolerance_out_of_range`. +This calculation runs only in the Rust client, never in JavaScript or QML. + +Prepared add-liquidity maximums are the quote's `actualAmountA` and `actualAmountB`, not the original +possibly lopsided caps. The exact quote is rerun with those fields before they are returned. This +keeps the eventual plan from spending above the displayed/current quoted deposits. + +## Ownership and failures + +The client validates account decoding, configured owners, canonical PDAs, pool/vault/token/LP +relationships, swap input/output pairing, and required input balances. Quote arithmetic failures +retain the stable `amm_program::quote::QuoteError` code. + +No request performs network I/O or checks an ImageID, release version, compatibility manifest, or +program allowlist. Deployment configuration is expected to select the corresponding AMM build. diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h new file mode 100644 index 00000000..24dc84c2 --- /dev/null +++ b/programs/amm/client/include/amm_client.h @@ -0,0 +1,46 @@ +#ifndef AMM_CLIENT_H +#define AMM_CLIENT_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Accepts a tagged UTF-8 JSON request and returns an owned UTF-8 JSON envelope. + * Supported operation tags: initialize, update_config, create_price_observations, + * create_oracle_price_account, create_pool, add_liquidity, remove_liquidity, + * swap_exact_input, swap_exact_output, and sync_reserves. + * Release the result with amm_client_free. + */ +char *amm_client_plan(const char *request_json); + +/* + * Accepts a tagged UTF-8 JSON request and returns an owned UTF-8 JSON envelope. + * Supported operation tags: protocol_constants, pair_order, create_pool, + * prepare_create_pool, preview_add_liquidity, prepare_add_liquidity, add_liquidity, + * preview_remove_liquidity, prepare_remove_liquidity, remove_liquidity, + * preview_swap_exact_input, prepare_swap_exact_input, swap_exact_input, + * preview_swap_exact_output, prepare_swap_exact_output, swap_exact_output, + * sync_reserves, and create_oracle_price_account. + * Release the result with amm_client_free. + */ +char *amm_client_quote(const char *request_json); + +/* + * Raw u128 and u64 values are unsigned decimal JSON strings. Program IDs and + * instruction words are JSON u32 arrays. Account IDs are base58 strings and + * account data is hexadecimal. Responses use {"ok":true,"value":...} or + * {"ok":false,"error":{"code":...,"message":...}}. + */ + +/* + * Releases a response returned by amm_client_plan or amm_client_quote. + * Passing NULL is allowed. Every non-NULL response must be released exactly once. + */ +void amm_client_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/programs/amm/client/src/error.rs b/programs/amm/client/src/error.rs new file mode 100644 index 00000000..c905bb56 --- /dev/null +++ b/programs/amm/client/src/error.rs @@ -0,0 +1,126 @@ +use std::{error::Error, fmt}; + +use nssa_core::{account::AccountId, program::ProgramId}; + +/// Failure while validating AMM client input or constructing a request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ClientError { + /// An account ID differs from its canonical or stored value. + AccountIdMismatch { + account: &'static str, + expected: AccountId, + actual: AccountId, + }, + /// An account owner differs from the owner required by the program. + ProgramOwnerMismatch { + account: &'static str, + expected: ProgramId, + actual: ProgramId, + }, + /// Account bytes cannot be decoded as the required program type. + InvalidAccountData { + account: &'static str, + expected: &'static str, + }, + /// A token account is not a fungible holding. + ExpectedFungibleToken { account: &'static str }, + /// A token holding points at the wrong definition. + TokenDefinitionMismatch { + account: &'static str, + expected: AccountId, + actual: AccountId, + }, + /// A holding cannot cover the amount required by a quoted operation. + InsufficientBalance { + account: &'static str, + available: u128, + required: u128, + }, + /// A pool was requested with the same token on both sides. + IdenticalTokenDefinitions, + /// Slippage basis points exceed one whole quoted amount. + SlippageToleranceOutOfRange { bps: u128, maximum_bps: u128 }, + /// A slippage-adjusted upper guard exceeds the chain amount range. + SlippageBoundOverflow { + quoted_amount: u128, + slippage_bps: u128, + }, + /// Program-owned quote logic rejected the requested transition. + Quote { + code: &'static str, + message: &'static str, + }, +} + +impl ClientError { + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::AccountIdMismatch { .. } => "account_id_mismatch", + Self::ProgramOwnerMismatch { .. } => "program_owner_mismatch", + Self::InvalidAccountData { .. } => "invalid_account_data", + Self::ExpectedFungibleToken { .. } => "expected_fungible_token", + Self::TokenDefinitionMismatch { .. } => "token_definition_mismatch", + Self::InsufficientBalance { .. } => "insufficient_balance", + Self::IdenticalTokenDefinitions => "identical_token_definitions", + Self::SlippageToleranceOutOfRange { .. } => "slippage_tolerance_out_of_range", + Self::SlippageBoundOverflow { .. } => "slippage_bound_overflow", + Self::Quote { code, .. } => code, + } + } +} + +impl fmt::Display for ClientError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AccountIdMismatch { account, .. } => { + write!(formatter, "{account} account ID mismatch") + } + Self::ProgramOwnerMismatch { account, .. } => { + write!(formatter, "{account} program owner mismatch") + } + Self::InvalidAccountData { account, expected } => { + write!( + formatter, + "{account} does not contain valid {expected} data" + ) + } + Self::ExpectedFungibleToken { account } => { + write!(formatter, "{account} must be a fungible token holding") + } + Self::TokenDefinitionMismatch { account, .. } => { + write!(formatter, "{account} token definition mismatch") + } + Self::InsufficientBalance { + account, + available, + required, + } => write!( + formatter, + "{account} balance {available} is less than required amount {required}" + ), + Self::IdenticalTokenDefinitions => { + formatter.write_str("pool token definitions must be distinct") + } + Self::SlippageToleranceOutOfRange { + bps, + maximum_bps, + } => write!( + formatter, + "slippage tolerance {bps} bps exceeds maximum {maximum_bps} bps" + ), + Self::SlippageBoundOverflow { + quoted_amount, + slippage_bps, + } => write!( + formatter, + "slippage-adjusted upper guard for {quoted_amount} at {slippage_bps} bps exceeds u128" + ), + Self::Quote { message, .. } => formatter.write_str(message), + } + } +} + +impl Error for ClientError {} diff --git a/programs/amm/client/src/ffi.rs b/programs/amm/client/src/ffi.rs new file mode 100644 index 00000000..ba608bb0 --- /dev/null +++ b/programs/amm/client/src/ffi.rs @@ -0,0 +1,172 @@ +//! C ABI for the lossless JSON AMM client protocol. + +#![allow( + unsafe_code, + reason = "raw C strings and paired allocation ownership are confined to this module" +)] + +use std::{ + ffi::{c_char, CStr, CString}, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use serde::Serialize; +use serde_json::Value; + +use crate::wire::{self, WireError}; + +type Operation = fn(Value) -> Result; + +#[derive(Serialize)] +struct Envelope { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl Envelope { + fn success(value: Value) -> Self { + Self { + ok: true, + value: Some(value), + error: None, + } + } + + fn failure(error: ErrorPayload) -> Self { + Self { + ok: false, + value: None, + error: Some(error), + } + } +} + +#[derive(Serialize)] +struct ErrorPayload { + code: String, + message: String, +} + +impl ErrorPayload { + fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + fn from_wire(error: WireError) -> Self { + Self::new(error.code(), error.to_string()) + } +} + +/// Calls one JSON operation and converts every outcome into an owned C string. +/// +/// # Safety +/// +/// `request_json` must be null or point to a live NUL-terminated byte string for the duration of +/// this call. A non-null return value must be released exactly once with [`amm_client_free`]. +unsafe fn call(request_json: *const c_char, operation: Operation) -> *mut c_char { + let result = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: The exported caller contract establishes pointer validity and lifetime. The + // helper validates nullness before constructing `CStr`. + let request = unsafe { request_value(request_json) }?; + operation(request).map_err(ErrorPayload::from_wire) + })); + + let envelope = match result { + Ok(Ok(value)) => Envelope::success(value), + Ok(Err(error)) => Envelope::failure(error), + Err(_) => Envelope::failure(ErrorPayload::new( + "internal_panic", + "AMM client operation panicked", + )), + }; + encode_envelope(&envelope) +} + +/// Reads and parses one caller-owned JSON C string. +/// +/// # Safety +/// +/// `request_json` must be null or point to a live NUL-terminated byte string for this call. +unsafe fn request_value(request_json: *const c_char) -> Result { + if request_json.is_null() { + return Err(ErrorPayload::new("null_request", "request pointer is null")); + } + + // SAFETY: Nullness was checked above. Remaining validity, lifetime, and NUL-termination are + // required by the exported caller contract. + let request = unsafe { CStr::from_ptr(request_json) }; + let request = request.to_str().map_err(|error| { + ErrorPayload::new("invalid_utf8", format!("request is not UTF-8: {error}")) + })?; + serde_json::from_str(request).map_err(|error| { + ErrorPayload::new( + "invalid_json", + format!("request is not valid JSON: {error}"), + ) + }) +} + +fn encode_envelope(envelope: &Envelope) -> *mut c_char { + let json = match serde_json::to_string(envelope) { + Ok(json) => json, + Err(_) => String::from( + r#"{"ok":false,"error":{"code":"response_serialization_failed","message":"response serialization failed"}}"#, + ), + }; + + match CString::new(json) { + Ok(value) => value.into_raw(), + Err(_) => CString::new( + r#"{"ok":false,"error":{"code":"response_contains_nul","message":"response contains NUL"}}"#, + ) + .map_or(std::ptr::null_mut(), CString::into_raw), + } +} + +/// Builds a canonical AMM transaction plan from a tagged JSON request. +/// +/// Returned JSON owns its memory and must be released with [`amm_client_free`]. +/// +/// # Safety +/// +/// `request_json` must be null or point to a live NUL-terminated UTF-8 byte string for this call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn amm_client_plan(request_json: *const c_char) -> *mut c_char { + // SAFETY: This function exposes the same pointer contract as `call`. + unsafe { call(request_json, wire::plan_json) } +} + +/// Evaluates a canonical AMM economic quote from a tagged JSON request. +/// +/// Returned JSON owns its memory and must be released with [`amm_client_free`]. +/// +/// # Safety +/// +/// `request_json` must be null or point to a live NUL-terminated UTF-8 byte string for this call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn amm_client_quote(request_json: *const c_char) -> *mut c_char { + // SAFETY: This function exposes the same pointer contract as `call`. + unsafe { call(request_json, wire::quote_json) } +} + +/// Releases a response returned by [`amm_client_plan`] or [`amm_client_quote`]. +/// +/// # Safety +/// +/// `value` must be null or a pointer returned by this library that has not already been freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn amm_client_free(value: *mut c_char) { + if value.is_null() { + return; + } + + // SAFETY: The caller contract requires a unique, live pointer produced by + // `CString::into_raw` in `encode_envelope`. + drop(unsafe { CString::from_raw(value) }); +} diff --git a/programs/amm/client/src/lib.rs b/programs/amm/client/src/lib.rs new file mode 100644 index 00000000..e384f38e --- /dev/null +++ b/programs/amm/client/src/lib.rs @@ -0,0 +1,26 @@ +//! Stateless AMM quoting and transaction planning for host consumers. + +pub mod error; +mod ffi; +pub mod plan; +pub mod quote; +pub mod slippage; +pub mod wire; + +pub use error::ClientError; +pub use ffi::{amm_client_free, amm_client_plan, amm_client_quote}; +pub use plan::{ + encode_instruction, plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, + plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input, + plan_swap_exact_output, plan_sync_reserves, plan_update_config, AccountRole, + AddLiquidityPlanInput, AmmContext, CreateOraclePriceAccountPlanInput, CreatePoolPlanInput, + CreatePriceObservationsPlanInput, InitializePlanInput, PlannedAccount, PoolContext, + RemoveLiquidityPlanInput, SwapExactInputPlanInput, SwapExactOutputPlanInput, + SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, +}; +pub use slippage::{ + maximum_guard_amount, minimum_guard_amount, prepare_add_liquidity, prepare_create_pool, + prepare_remove_liquidity, prepare_swap_exact_input, prepare_swap_exact_output, + PreparedAddLiquidity, PreparedCreatePool, PreparedRemoveLiquidity, PreparedSwapExactInput, + PreparedSwapExactOutput, SlippageTolerance, SLIPPAGE_BPS_DENOMINATOR, +}; diff --git a/programs/amm/client/src/plan.rs b/programs/amm/client/src/plan.rs new file mode 100644 index 00000000..29abdee3 --- /dev/null +++ b/programs/amm/client/src/plan.rs @@ -0,0 +1,904 @@ +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, AmmConfig, Instruction, PoolDefinition, +}; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use nssa_core::{ + account::AccountId, + program::{InstructionData, ProgramId}, +}; +use twap_oracle_core::{ + compute_current_tick_account_pda, compute_oracle_price_account_pda, + compute_price_observations_pda, +}; + +use crate::ClientError; + +/// Configured AMM program context used by deterministic planners. +/// +/// `amm_program_id` is accepted optimistically. The client derives addresses for that program but +/// does not perform release, ImageID, or deployment-version checks. +#[derive(Clone)] +pub struct AmmContext { + pub amm_program_id: ProgramId, + pub config: AmmConfig, +} + +impl AmmContext { + #[must_use] + pub const fn new(amm_program_id: ProgramId, config: AmmConfig) -> Self { + Self { + amm_program_id, + config, + } + } + + #[must_use] + pub fn config_id(&self) -> AccountId { + compute_config_pda(self.amm_program_id) + } + + #[must_use] + pub const fn token_program_id(&self) -> ProgramId { + self.config.token_program_id + } + + #[must_use] + pub const fn twap_oracle_program_id(&self) -> ProgramId { + self.config.twap_oracle_program_id + } +} + +/// An initialized pool and its canonical stored identity fields. +#[derive(Clone, Copy)] +pub struct PoolContext<'a> { + pool_id: AccountId, + pool: &'a PoolDefinition, +} + +impl<'a> PoolContext<'a> { + /// Validates the stored pool identity fields against canonical AMM PDA derivation. + pub fn new( + context: &AmmContext, + pool_id: AccountId, + pool: &'a PoolDefinition, + ) -> Result { + if pool.definition_token_a_id == pool.definition_token_b_id { + return Err(ClientError::IdenticalTokenDefinitions); + } + + validate_account_id( + "pool", + compute_pool_pda( + context.amm_program_id, + pool.definition_token_a_id, + pool.definition_token_b_id, + ), + pool_id, + )?; + validate_account_id( + "vault_a", + compute_vault_pda(context.amm_program_id, pool_id, pool.definition_token_a_id), + pool.vault_a_id, + )?; + validate_account_id( + "vault_b", + compute_vault_pda(context.amm_program_id, pool_id, pool.definition_token_b_id), + pool.vault_b_id, + )?; + validate_account_id( + "pool_definition_lp", + compute_liquidity_token_pda(context.amm_program_id, pool_id), + pool.liquidity_pool_id, + )?; + + Ok(Self { pool_id, pool }) + } + + #[must_use] + pub const fn pool_id(&self) -> AccountId { + self.pool_id + } + + #[must_use] + pub const fn pool(&self) -> &PoolDefinition { + self.pool + } +} + +/// Semantic name of an account in an AMM instruction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum AccountRole { + Config, + Authority, + Pool, + VaultA, + VaultB, + PoolDefinitionLp, + LpLockHolding, + UserHoldingA, + UserHoldingB, + UserHoldingLp, + UserInputHolding, + UserOutputHolding, + CurrentTickAccount, + PriceObservations, + OraclePriceAccount, + Clock, +} + +impl AccountRole { + /// Exact role name emitted by the AMM IDL. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Config => "config", + Self::Authority => "authority", + Self::Pool => "pool", + Self::VaultA => "vault_a", + Self::VaultB => "vault_b", + Self::PoolDefinitionLp => "pool_definition_lp", + Self::LpLockHolding => "lp_lock_holding", + Self::UserHoldingA => "user_holding_a", + Self::UserHoldingB => "user_holding_b", + Self::UserHoldingLp => "user_holding_lp", + Self::UserInputHolding => "user_input_holding", + Self::UserOutputHolding => "user_output_holding", + Self::CurrentTickAccount => "current_tick_account", + Self::PriceObservations => "price_observations", + Self::OraclePriceAccount => "oracle_price_account", + Self::Clock => "clock", + } + } +} + +/// Ordered account row required by an AMM instruction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PlannedAccount { + id: AccountId, + role: AccountRole, + writable: bool, + signer: bool, + init: bool, +} + +impl PlannedAccount { + #[must_use] + pub const fn id(&self) -> AccountId { + self.id + } + + #[must_use] + pub const fn role(&self) -> AccountRole { + self.role + } + + #[must_use] + pub const fn writable(&self) -> bool { + self.writable + } + + #[must_use] + pub const fn signer(&self) -> bool { + self.signer + } + + #[must_use] + pub const fn init(&self) -> bool { + self.init + } +} + +/// Canonical instruction plus ordered accounts for wallet submission. +pub struct TransactionPlan { + program_id: ProgramId, + instruction: Instruction, + accounts: Vec, +} + +impl TransactionPlan { + fn new(program_id: ProgramId, instruction: Instruction, accounts: Vec) -> Self { + Self { + program_id, + instruction, + accounts, + } + } + + #[must_use] + pub const fn program_id(&self) -> ProgramId { + self.program_id + } + + #[must_use] + pub const fn instruction(&self) -> &Instruction { + &self.instruction + } + + /// Exact guest-compatible RISC Zero Serde instruction words. + pub fn instruction_data(&self) -> risc0_zkvm::serde::Result { + encode_instruction(&self.instruction) + } + + #[must_use] + pub fn accounts(&self) -> &[PlannedAccount] { + &self.accounts + } + + #[must_use] + pub fn account_ids(&self) -> Vec { + self.accounts.iter().map(PlannedAccount::id).collect() + } + + /// One signer requirement for each ordered account ID. + #[must_use] + pub fn signer_flags(&self) -> Vec { + self.accounts.iter().map(PlannedAccount::signer).collect() + } + + /// Signer IDs in their original account-list order. + #[must_use] + pub fn signer_account_ids(&self) -> Vec { + self.accounts + .iter() + .filter(|account| account.signer()) + .map(PlannedAccount::id) + .collect() + } + + /// Guest instruction name, kept exhaustive over the canonical enum. + #[must_use] + pub const fn instruction_name(&self) -> &'static str { + match &self.instruction { + Instruction::Initialize { .. } => "initialize", + Instruction::UpdateConfig { .. } => "update_config", + Instruction::CreatePriceObservations { .. } => "create_price_observations", + Instruction::CreateOraclePriceAccount { .. } => "create_oracle_price_account", + Instruction::NewDefinition { .. } => "new_definition", + Instruction::AddLiquidity { .. } => "add_liquidity", + Instruction::RemoveLiquidity { .. } => "remove_liquidity", + Instruction::SwapExactInput { .. } => "swap_exact_input", + Instruction::SwapExactOutput { .. } => "swap_exact_output", + Instruction::SyncReserves => "sync_reserves", + } + } +} + +/// Encode the actual instruction enum through the codec consumed by the AMM guest. +pub fn encode_instruction(instruction: &Instruction) -> risc0_zkvm::serde::Result { + risc0_zkvm::serde::to_vec(instruction) +} + +pub struct InitializePlanInput { + pub amm_program_id: ProgramId, + pub token_program_id: ProgramId, + pub twap_oracle_program_id: ProgramId, + pub authority: AccountId, +} + +pub struct UpdateConfigPlanInput<'a> { + pub context: &'a AmmContext, + pub token_program_id: Option, + pub twap_oracle_program_id: Option, + pub new_authority: Option, +} + +pub struct CreatePriceObservationsPlanInput<'a> { + pub context: &'a AmmContext, + pub pool_id: AccountId, + pub window_duration: u64, +} + +pub struct CreateOraclePriceAccountPlanInput<'a> { + pub context: &'a AmmContext, + pub pool_id: AccountId, + pub window_duration: u64, +} + +pub struct CreatePoolPlanInput<'a> { + pub context: &'a AmmContext, + pub token_a_definition_id: AccountId, + pub token_b_definition_id: AccountId, + pub user_holding_a: AccountId, + pub user_holding_b: AccountId, + pub user_holding_lp: AccountId, + pub token_a_amount: u128, + pub token_b_amount: u128, + pub fees: u128, + pub deadline: u64, +} + +pub struct AddLiquidityPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, + pub user_holding_a: AccountId, + pub user_holding_b: AccountId, + pub user_holding_lp: AccountId, + pub min_amount_liquidity: u128, + pub max_amount_to_add_token_a: u128, + pub max_amount_to_add_token_b: u128, + pub deadline: u64, +} + +pub struct RemoveLiquidityPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, + pub user_holding_a: AccountId, + pub user_holding_b: AccountId, + pub user_holding_lp: AccountId, + pub remove_liquidity_amount: u128, + pub min_amount_to_remove_token_a: u128, + pub min_amount_to_remove_token_b: u128, + pub deadline: u64, +} + +pub struct SwapExactInputPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, + pub user_input_holding: AccountId, + pub user_output_holding: AccountId, + pub swap_amount_in: u128, + pub min_amount_out: u128, + pub deadline: u64, +} + +pub struct SwapExactOutputPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, + pub user_input_holding: AccountId, + pub user_output_holding: AccountId, + pub exact_amount_out: u128, + pub max_amount_in: u128, + pub deadline: u64, +} + +pub struct SyncReservesPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, +} + +#[must_use] +pub fn plan_initialize(input: InitializePlanInput) -> TransactionPlan { + TransactionPlan::new( + input.amm_program_id, + Instruction::Initialize { + token_program_id: input.token_program_id, + twap_oracle_program_id: input.twap_oracle_program_id, + authority: input.authority, + }, + vec![planned( + compute_config_pda(input.amm_program_id), + AccountRole::Config, + true, + false, + true, + )], + ) +} + +#[must_use] +pub fn plan_update_config(input: UpdateConfigPlanInput<'_>) -> TransactionPlan { + TransactionPlan::new( + input.context.amm_program_id, + Instruction::UpdateConfig { + token_program_id: input.token_program_id, + twap_oracle_program_id: input.twap_oracle_program_id, + new_authority: input.new_authority, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + true, + false, + false, + ), + planned( + input.context.config.authority, + AccountRole::Authority, + false, + true, + false, + ), + ], + ) +} + +#[must_use] +pub fn plan_create_price_observations( + input: CreatePriceObservationsPlanInput<'_>, +) -> TransactionPlan { + let oracle_program_id = input.context.twap_oracle_program_id(); + TransactionPlan::new( + input.context.amm_program_id, + Instruction::CreatePriceObservations { + window_duration: input.window_duration, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool_id, AccountRole::Pool, false, false, false), + planned( + compute_current_tick_account_pda(oracle_program_id, input.pool_id), + AccountRole::CurrentTickAccount, + false, + false, + false, + ), + planned( + compute_price_observations_pda( + oracle_program_id, + input.pool_id, + input.window_duration, + ), + AccountRole::PriceObservations, + true, + false, + true, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +#[must_use] +pub fn plan_create_oracle_price_account( + input: CreateOraclePriceAccountPlanInput<'_>, +) -> TransactionPlan { + let oracle_program_id = input.context.twap_oracle_program_id(); + TransactionPlan::new( + input.context.amm_program_id, + Instruction::CreateOraclePriceAccount { + window_duration: input.window_duration, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool_id, AccountRole::Pool, false, false, false), + planned( + compute_oracle_price_account_pda( + oracle_program_id, + input.pool_id, + input.window_duration, + ), + AccountRole::OraclePriceAccount, + true, + false, + true, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +pub fn plan_create_pool(input: CreatePoolPlanInput<'_>) -> Result { + if input.token_a_definition_id == input.token_b_definition_id { + return Err(ClientError::IdenticalTokenDefinitions); + } + + let program_id = input.context.amm_program_id; + let pool_id = compute_pool_pda( + program_id, + input.token_a_definition_id, + input.token_b_definition_id, + ); + let vault_a = compute_vault_pda(program_id, pool_id, input.token_a_definition_id); + let vault_b = compute_vault_pda(program_id, pool_id, input.token_b_definition_id); + let liquidity_token = compute_liquidity_token_pda(program_id, pool_id); + let lock_holding = compute_lp_lock_holding_pda(program_id, pool_id); + let current_tick = + compute_current_tick_account_pda(input.context.twap_oracle_program_id(), pool_id); + + Ok(TransactionPlan::new( + program_id, + Instruction::NewDefinition { + token_a_amount: input.token_a_amount, + token_b_amount: input.token_b_amount, + fees: input.fees, + deadline: input.deadline, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(pool_id, AccountRole::Pool, true, false, true), + planned(vault_a, AccountRole::VaultA, true, false, false), + planned(vault_b, AccountRole::VaultB, true, false, false), + planned( + liquidity_token, + AccountRole::PoolDefinitionLp, + true, + false, + true, + ), + planned(lock_holding, AccountRole::LpLockHolding, true, false, true), + planned( + input.user_holding_a, + AccountRole::UserHoldingA, + true, + true, + false, + ), + planned( + input.user_holding_b, + AccountRole::UserHoldingB, + true, + true, + false, + ), + planned( + input.user_holding_lp, + AccountRole::UserHoldingLp, + true, + true, + false, + ), + planned( + current_tick, + AccountRole::CurrentTickAccount, + true, + false, + true, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + )) +} + +#[must_use] +pub fn plan_add_liquidity(input: AddLiquidityPlanInput<'_>) -> TransactionPlan { + let tick = current_tick(input.context, input.pool.pool_id); + TransactionPlan::new( + input.context.amm_program_id, + Instruction::AddLiquidity { + min_amount_liquidity: input.min_amount_liquidity, + max_amount_to_add_token_a: input.max_amount_to_add_token_a, + max_amount_to_add_token_b: input.max_amount_to_add_token_b, + deadline: input.deadline, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool.pool_id, AccountRole::Pool, true, false, false), + planned( + input.pool.pool.vault_a_id, + AccountRole::VaultA, + true, + false, + false, + ), + planned( + input.pool.pool.vault_b_id, + AccountRole::VaultB, + true, + false, + false, + ), + planned( + input.pool.pool.liquidity_pool_id, + AccountRole::PoolDefinitionLp, + true, + false, + false, + ), + planned( + input.user_holding_a, + AccountRole::UserHoldingA, + true, + true, + false, + ), + planned( + input.user_holding_b, + AccountRole::UserHoldingB, + true, + true, + false, + ), + planned( + input.user_holding_lp, + AccountRole::UserHoldingLp, + true, + false, + false, + ), + planned(tick, AccountRole::CurrentTickAccount, true, false, false), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +#[must_use] +pub fn plan_remove_liquidity(input: RemoveLiquidityPlanInput<'_>) -> TransactionPlan { + let tick = current_tick(input.context, input.pool.pool_id); + TransactionPlan::new( + input.context.amm_program_id, + Instruction::RemoveLiquidity { + remove_liquidity_amount: input.remove_liquidity_amount, + min_amount_to_remove_token_a: input.min_amount_to_remove_token_a, + min_amount_to_remove_token_b: input.min_amount_to_remove_token_b, + deadline: input.deadline, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool.pool_id, AccountRole::Pool, true, false, false), + planned( + input.pool.pool.vault_a_id, + AccountRole::VaultA, + true, + false, + false, + ), + planned( + input.pool.pool.vault_b_id, + AccountRole::VaultB, + true, + false, + false, + ), + planned( + input.pool.pool.liquidity_pool_id, + AccountRole::PoolDefinitionLp, + true, + false, + false, + ), + planned( + input.user_holding_a, + AccountRole::UserHoldingA, + true, + false, + false, + ), + planned( + input.user_holding_b, + AccountRole::UserHoldingB, + true, + false, + false, + ), + planned( + input.user_holding_lp, + AccountRole::UserHoldingLp, + true, + true, + false, + ), + planned(tick, AccountRole::CurrentTickAccount, true, false, false), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +#[must_use] +pub fn plan_swap_exact_input(input: SwapExactInputPlanInput<'_>) -> TransactionPlan { + swap_plan( + input.context, + input.pool, + input.user_input_holding, + input.user_output_holding, + Instruction::SwapExactInput { + swap_amount_in: input.swap_amount_in, + min_amount_out: input.min_amount_out, + deadline: input.deadline, + }, + ) +} + +#[must_use] +pub fn plan_swap_exact_output(input: SwapExactOutputPlanInput<'_>) -> TransactionPlan { + swap_plan( + input.context, + input.pool, + input.user_input_holding, + input.user_output_holding, + Instruction::SwapExactOutput { + exact_amount_out: input.exact_amount_out, + max_amount_in: input.max_amount_in, + deadline: input.deadline, + }, + ) +} + +#[must_use] +pub fn plan_sync_reserves(input: SyncReservesPlanInput<'_>) -> TransactionPlan { + TransactionPlan::new( + input.context.amm_program_id, + Instruction::SyncReserves, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool.pool_id, AccountRole::Pool, true, false, false), + planned( + input.pool.pool.vault_a_id, + AccountRole::VaultA, + false, + false, + false, + ), + planned( + input.pool.pool.vault_b_id, + AccountRole::VaultB, + false, + false, + false, + ), + planned( + current_tick(input.context, input.pool.pool_id), + AccountRole::CurrentTickAccount, + true, + false, + false, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +fn swap_plan( + context: &AmmContext, + pool: PoolContext<'_>, + input_holding: AccountId, + output_holding: AccountId, + instruction: Instruction, +) -> TransactionPlan { + TransactionPlan::new( + context.amm_program_id, + instruction, + vec![ + planned( + context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(pool.pool_id, AccountRole::Pool, true, false, false), + planned( + pool.pool.vault_a_id, + AccountRole::VaultA, + true, + false, + false, + ), + planned( + pool.pool.vault_b_id, + AccountRole::VaultB, + true, + false, + false, + ), + planned( + input_holding, + AccountRole::UserInputHolding, + true, + true, + false, + ), + planned( + output_holding, + AccountRole::UserOutputHolding, + true, + false, + false, + ), + planned( + current_tick(context, pool.pool_id), + AccountRole::CurrentTickAccount, + true, + false, + false, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +fn current_tick(context: &AmmContext, pool_id: AccountId) -> AccountId { + compute_current_tick_account_pda(context.twap_oracle_program_id(), pool_id) +} + +fn validate_account_id( + account: &'static str, + expected: AccountId, + actual: AccountId, +) -> Result<(), ClientError> { + if expected == actual { + Ok(()) + } else { + Err(ClientError::AccountIdMismatch { + account, + expected, + actual, + }) + } +} + +const fn planned( + id: AccountId, + role: AccountRole, + writable: bool, + signer: bool, + init: bool, +) -> PlannedAccount { + PlannedAccount { + id, + role, + writable, + signer, + init, + } +} diff --git a/programs/amm/client/src/quote.rs b/programs/amm/client/src/quote.rs new file mode 100644 index 00000000..cb06ce16 --- /dev/null +++ b/programs/amm/client/src/quote.rs @@ -0,0 +1,698 @@ +//! Validated account snapshots and high-level AMM quote orchestration. +//! +//! This module validates fetched protocol accounts, then delegates every economic calculation to +//! [`amm_program::quote`]. It performs no RPC, signing, submission, floating-point conversion, or +//! runtime program-version check. + +use amm_core::{compute_config_pda, AmmConfig, PoolDefinition}; +use amm_program::quote as program_quote; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; +use token_core::{TokenDefinition, TokenHolding}; + +use crate::{AmmContext, ClientError, PoolContext}; + +/// An immutable fetched account paired with the ID used to fetch it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountSnapshot { + account_id: AccountId, + account: Account, +} + +impl AccountSnapshot { + /// Creates an account snapshot from canonical NSSA account data. + #[must_use] + pub fn new(account_id: AccountId, account: Account) -> Self { + Self { + account_id, + account, + } + } + + /// Returns the fetched account ID. + #[must_use] + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Returns the fetched canonical account. + #[must_use] + pub const fn account(&self) -> &Account { + &self.account + } +} + +impl AmmContext { + /// Validates and decodes the singleton config account for the supplied AMM program ID. + /// + /// The supplied program ID is used optimistically. This checks protocol ownership and the + /// config PDA, but intentionally performs no ImageID, version, or build-compatibility lookup. + pub fn from_config_account( + amm_program_id: ProgramId, + config_account: &AccountSnapshot, + ) -> Result { + ensure_account_id( + "AMM config", + config_account, + compute_config_pda(amm_program_id), + )?; + ensure_program_owner("AMM config", config_account, amm_program_id)?; + let config = AmmConfig::try_from(&config_account.account.data).map_err(|_| { + ClientError::InvalidAccountData { + account: "AMM config", + expected: "AmmConfig", + } + })?; + + Ok(Self::new(amm_program_id, config)) + } +} + +/// A token definition proven to be a configured-token-program fungible definition. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedFungibleDefinition { + account_id: AccountId, + token_program_id: ProgramId, + total_supply: u128, + authority: Option, +} + +impl ValidatedFungibleDefinition { + /// Validates a fungible token definition account against an AMM context. + pub fn new( + context: &AmmContext, + definition_account: &AccountSnapshot, + ) -> Result { + ensure_program_owner( + "token definition", + definition_account, + context.token_program_id(), + )?; + let definition = + TokenDefinition::try_from(&definition_account.account.data).map_err(|_| { + ClientError::InvalidAccountData { + account: "token definition", + expected: "TokenDefinition", + } + })?; + let TokenDefinition::Fungible { + total_supply, + authority, + .. + } = definition + else { + return Err(ClientError::ExpectedFungibleToken { + account: "token definition", + }); + }; + + Ok(Self { + account_id: definition_account.account_id, + token_program_id: context.token_program_id(), + total_supply, + authority, + }) + } + + /// Returns the token definition account ID. + #[must_use] + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Returns the exact raw supply stored by the token program. + #[must_use] + pub const fn total_supply(&self) -> u128 { + self.total_supply + } + + /// Returns the token definition's current mint authority. + #[must_use] + pub const fn authority(&self) -> Option { + self.authority + } +} + +/// A token holding proven to be fungible, configured-token-program owned, and tied to an expected +/// definition. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedFungibleHolding { + account_id: AccountId, + definition_id: AccountId, + balance: u128, + token_program_id: ProgramId, +} + +impl ValidatedFungibleHolding { + /// Validates a fungible holding against an expected token definition. + pub fn new( + context: &AmmContext, + holding_account: &AccountSnapshot, + expected_definition: &ValidatedFungibleDefinition, + ) -> Result { + ensure_definition_context(context, expected_definition, "expected token definition")?; + Self::for_definition_id(context, holding_account, expected_definition.account_id) + } + + fn for_definition_id( + context: &AmmContext, + holding_account: &AccountSnapshot, + expected_definition_id: AccountId, + ) -> Result { + ensure_program_owner("token holding", holding_account, context.token_program_id())?; + let holding = TokenHolding::try_from(&holding_account.account.data).map_err(|_| { + ClientError::InvalidAccountData { + account: "token holding", + expected: "TokenHolding", + } + })?; + let TokenHolding::Fungible { + definition_id, + balance, + } = holding + else { + return Err(ClientError::ExpectedFungibleToken { + account: "token holding", + }); + }; + if definition_id != expected_definition_id { + return Err(ClientError::TokenDefinitionMismatch { + account: "token holding", + expected: expected_definition_id, + actual: definition_id, + }); + } + + Ok(Self { + account_id: holding_account.account_id, + definition_id, + balance, + token_program_id: context.token_program_id(), + }) + } + + /// Returns the holding account ID. + #[must_use] + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Returns the held token definition account ID. + #[must_use] + pub const fn definition_id(&self) -> AccountId { + self.definition_id + } + + /// Returns the exact raw fungible balance. + #[must_use] + pub const fn balance(&self) -> u128 { + self.balance + } +} + +/// A decoded pool whose owner, PDA, stored account IDs, vault holdings, and fungible token +/// definitions have been validated together. +#[derive(Clone)] +pub struct ValidatedPoolSnapshot { + pool_id: AccountId, + pool: PoolDefinition, + token_a_definition: ValidatedFungibleDefinition, + token_b_definition: ValidatedFungibleDefinition, + liquidity_definition: ValidatedFungibleDefinition, + vault_a: ValidatedFungibleHolding, + vault_b: ValidatedFungibleHolding, +} + +impl ValidatedPoolSnapshot { + /// Validates a complete initialized pool snapshot. + pub fn new( + context: &AmmContext, + pool_account: &AccountSnapshot, + token_a_definition_account: &AccountSnapshot, + token_b_definition_account: &AccountSnapshot, + vault_a_account: &AccountSnapshot, + vault_b_account: &AccountSnapshot, + liquidity_definition_account: &AccountSnapshot, + ) -> Result { + ensure_program_owner("AMM pool", pool_account, context.amm_program_id)?; + let pool = PoolDefinition::try_from(&pool_account.account.data).map_err(|_| { + ClientError::InvalidAccountData { + account: "AMM pool", + expected: "PoolDefinition", + } + })?; + if pool.definition_token_a_id == pool.definition_token_b_id { + return Err(ClientError::IdenticalTokenDefinitions); + } + PoolContext::new(context, pool_account.account_id, &pool)?; + + let token_a_definition = + ValidatedFungibleDefinition::new(context, token_a_definition_account)?; + ensure_definition_id( + "token A definition", + &token_a_definition, + pool.definition_token_a_id, + )?; + let token_b_definition = + ValidatedFungibleDefinition::new(context, token_b_definition_account)?; + ensure_definition_id( + "token B definition", + &token_b_definition, + pool.definition_token_b_id, + )?; + let liquidity_definition = + ValidatedFungibleDefinition::new(context, liquidity_definition_account)?; + ensure_definition_id( + "liquidity definition", + &liquidity_definition, + pool.liquidity_pool_id, + )?; + if liquidity_definition.total_supply != pool.liquidity_pool_supply { + return Err(ClientError::InvalidAccountData { + account: "liquidity definition", + expected: "fungible LP definition with supply equal to pool liquidity supply", + }); + } + if liquidity_definition.authority != Some(pool.liquidity_pool_id) { + return Err(ClientError::InvalidAccountData { + account: "liquidity definition", + expected: "self-authorized fungible LP definition", + }); + } + + ensure_account_id("vault A", vault_a_account, pool.vault_a_id)?; + ensure_account_id("vault B", vault_b_account, pool.vault_b_id)?; + let vault_a = ValidatedFungibleHolding::for_definition_id( + context, + vault_a_account, + pool.definition_token_a_id, + )?; + let vault_b = ValidatedFungibleHolding::for_definition_id( + context, + vault_b_account, + pool.definition_token_b_id, + )?; + + Ok(Self { + pool_id: pool_account.account_id, + pool, + token_a_definition, + token_b_definition, + liquidity_definition, + vault_a, + vault_b, + }) + } + + /// Returns the pool account ID. + #[must_use] + pub const fn pool_id(&self) -> AccountId { + self.pool_id + } + + /// Returns the decoded pool state. + #[must_use] + pub const fn pool(&self) -> &PoolDefinition { + &self.pool + } + + /// Returns the validated token-A definition. + #[must_use] + pub const fn token_a_definition(&self) -> &ValidatedFungibleDefinition { + &self.token_a_definition + } + + /// Returns the validated token-B definition. + #[must_use] + pub const fn token_b_definition(&self) -> &ValidatedFungibleDefinition { + &self.token_b_definition + } + + /// Returns the validated liquidity-token definition. + #[must_use] + pub const fn liquidity_definition(&self) -> &ValidatedFungibleDefinition { + &self.liquidity_definition + } + + /// Returns the validated token-A vault holding. + #[must_use] + pub const fn vault_a(&self) -> &ValidatedFungibleHolding { + &self.vault_a + } + + /// Returns the validated token-B vault holding. + #[must_use] + pub const fn vault_b(&self) -> &ValidatedFungibleHolding { + &self.vault_b + } +} + +impl From for ClientError { + fn from(error: program_quote::QuoteError) -> Self { + Self::Quote { + code: error.code(), + message: error.message(), + } + } +} + +/// Resolves caller token order against a validated pool. +pub fn pair_order( + snapshot: &ValidatedPoolSnapshot, + first_token: &ValidatedFungibleDefinition, + second_token: &ValidatedFungibleDefinition, +) -> Result { + Ok(program_quote::pair_order( + &snapshot.pool, + first_token.account_id, + second_token.account_id, + )?) +} + +/// Quotes initial pool liquidity for two validated fungible definitions. +pub fn create_pool( + context: &AmmContext, + token_a: &ValidatedFungibleDefinition, + token_b: &ValidatedFungibleDefinition, + token_a_amount: u128, + token_b_amount: u128, + fee_bps: u128, +) -> Result { + ensure_definition_context(context, token_a, "token A definition")?; + ensure_definition_context(context, token_b, "token B definition")?; + if token_a.account_id == token_b.account_id { + return Err(ClientError::IdenticalTokenDefinitions); + } + + Ok(program_quote::create_pool( + token_a_amount, + token_b_amount, + fee_bps, + )?) +} + +/// Previews an add-liquidity transition from validated pool and vault state. +pub fn preview_add_liquidity( + snapshot: &ValidatedPoolSnapshot, + max_amount_a: u128, + max_amount_b: u128, +) -> Result { + Ok(program_quote::preview_add_liquidity( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + max_amount_a, + max_amount_b, + )?) +} + +/// Quotes an add-liquidity transition with the exact execution guard. +pub fn add_liquidity( + snapshot: &ValidatedPoolSnapshot, + max_amount_a: u128, + max_amount_b: u128, + minimum_liquidity: u128, +) -> Result { + Ok(program_quote::add_liquidity( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + max_amount_a, + max_amount_b, + minimum_liquidity, + )?) +} + +/// Previews a remove-liquidity transition using a validated LP holding. +pub fn preview_remove_liquidity( + snapshot: &ValidatedPoolSnapshot, + user_liquidity: &ValidatedFungibleHolding, + remove_liquidity_amount: u128, +) -> Result { + ensure_pool_holding( + snapshot, + user_liquidity, + snapshot.pool.liquidity_pool_id, + "user liquidity holding", + )?; + Ok(program_quote::preview_remove_liquidity( + &snapshot.pool, + user_liquidity.balance, + remove_liquidity_amount, + )?) +} + +/// Quotes a remove-liquidity transition with the exact execution guards. +pub fn remove_liquidity( + snapshot: &ValidatedPoolSnapshot, + user_liquidity: &ValidatedFungibleHolding, + remove_liquidity_amount: u128, + minimum_amount_a: u128, + minimum_amount_b: u128, +) -> Result { + ensure_pool_holding( + snapshot, + user_liquidity, + snapshot.pool.liquidity_pool_id, + "user liquidity holding", + )?; + Ok(program_quote::remove_liquidity( + &snapshot.pool, + user_liquidity.balance, + remove_liquidity_amount, + minimum_amount_a, + minimum_amount_b, + )?) +} + +/// Previews an exact-input swap, deriving direction from the validated input holding. +pub fn preview_swap_exact_input( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + amount_in: u128, +) -> Result { + let direction = validated_swap_direction(snapshot, user_input, user_output)?; + let quote = program_quote::preview_swap_exact_input( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + direction, + amount_in, + )?; + ensure_available_balance(user_input, quote.amount_in, "user input holding")?; + Ok(quote) +} + +/// Quotes an exact-input swap with its exact minimum-output guard. +pub fn swap_exact_input( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + amount_in: u128, + minimum_amount_out: u128, +) -> Result { + let direction = validated_swap_direction(snapshot, user_input, user_output)?; + let quote = program_quote::swap_exact_input( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + direction, + amount_in, + minimum_amount_out, + )?; + ensure_available_balance(user_input, quote.amount_in, "user input holding")?; + Ok(quote) +} + +/// Previews an exact-output swap, deriving direction from the validated input holding. +pub fn preview_swap_exact_output( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + exact_amount_out: u128, +) -> Result { + let direction = validated_swap_direction(snapshot, user_input, user_output)?; + let quote = program_quote::preview_swap_exact_output( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + direction, + exact_amount_out, + )?; + ensure_available_balance(user_input, quote.amount_in, "user input holding")?; + Ok(quote) +} + +/// Quotes an exact-output swap with its exact maximum-input guard. +pub fn swap_exact_output( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + exact_amount_out: u128, + maximum_amount_in: u128, +) -> Result { + let direction = validated_swap_direction(snapshot, user_input, user_output)?; + let quote = program_quote::swap_exact_output( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + direction, + exact_amount_out, + maximum_amount_in, + )?; + ensure_available_balance(user_input, quote.amount_in, "user input holding")?; + Ok(quote) +} + +/// Quotes reserve synchronization from validated pool and vault state. +pub fn sync_reserves( + snapshot: &ValidatedPoolSnapshot, +) -> Result { + Ok(program_quote::sync_reserves( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + )?) +} + +/// Quotes pool-derived initialization values for an oracle price account. +pub fn create_oracle_price_account( + snapshot: &ValidatedPoolSnapshot, + window_duration: u64, +) -> Result { + Ok(program_quote::create_oracle_price_account( + &snapshot.pool, + window_duration, + )?) +} + +fn validated_swap_direction( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, +) -> Result { + ensure_holding_context(snapshot, user_input, "user input holding")?; + ensure_holding_context(snapshot, user_output, "user output holding")?; + let direction = program_quote::swap_direction(&snapshot.pool, user_input.definition_id)?; + let expected_output_definition = match direction { + program_quote::SwapDirection::AToB => snapshot.pool.definition_token_b_id, + program_quote::SwapDirection::BToA => snapshot.pool.definition_token_a_id, + }; + ensure_pool_holding( + snapshot, + user_output, + expected_output_definition, + "user output holding", + )?; + Ok(direction) +} + +fn ensure_account_id( + account_name: &'static str, + snapshot: &AccountSnapshot, + expected: AccountId, +) -> Result<(), ClientError> { + if snapshot.account_id != expected { + return Err(ClientError::AccountIdMismatch { + account: account_name, + expected, + actual: snapshot.account_id, + }); + } + Ok(()) +} + +fn ensure_program_owner( + account_name: &'static str, + snapshot: &AccountSnapshot, + expected: ProgramId, +) -> Result<(), ClientError> { + if snapshot.account.program_owner != expected { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected, + actual: snapshot.account.program_owner, + }); + } + Ok(()) +} + +fn ensure_definition_context( + context: &AmmContext, + definition: &ValidatedFungibleDefinition, + account_name: &'static str, +) -> Result<(), ClientError> { + if definition.token_program_id != context.token_program_id() { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected: context.token_program_id(), + actual: definition.token_program_id, + }); + } + Ok(()) +} + +fn ensure_definition_id( + account_name: &'static str, + definition: &ValidatedFungibleDefinition, + expected: AccountId, +) -> Result<(), ClientError> { + if definition.account_id != expected { + return Err(ClientError::TokenDefinitionMismatch { + account: account_name, + expected, + actual: definition.account_id, + }); + } + Ok(()) +} + +fn ensure_holding_context( + snapshot: &ValidatedPoolSnapshot, + holding: &ValidatedFungibleHolding, + account_name: &'static str, +) -> Result<(), ClientError> { + if holding.token_program_id != snapshot.vault_a.token_program_id { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected: snapshot.vault_a.token_program_id, + actual: holding.token_program_id, + }); + } + Ok(()) +} + +fn ensure_pool_holding( + snapshot: &ValidatedPoolSnapshot, + holding: &ValidatedFungibleHolding, + expected_definition_id: AccountId, + account_name: &'static str, +) -> Result<(), ClientError> { + ensure_holding_context(snapshot, holding, account_name)?; + if holding.definition_id != expected_definition_id { + return Err(ClientError::TokenDefinitionMismatch { + account: account_name, + expected: expected_definition_id, + actual: holding.definition_id, + }); + } + Ok(()) +} + +fn ensure_available_balance( + holding: &ValidatedFungibleHolding, + required: u128, + account_name: &'static str, +) -> Result<(), ClientError> { + if holding.balance < required { + return Err(ClientError::InsufficientBalance { + account: account_name, + available: holding.balance, + required, + }); + } + Ok(()) +} diff --git a/programs/amm/client/src/slippage.rs b/programs/amm/client/src/slippage.rs new file mode 100644 index 00000000..6baa19ca --- /dev/null +++ b/programs/amm/client/src/slippage.rs @@ -0,0 +1,267 @@ +//! Integer-only construction of AMM instruction guards from validated quotes. + +use amm_core::{checked_mul_div_ceil, checked_mul_div_floor, FEE_BPS_DENOMINATOR}; +use amm_program::quote::{AddLiquidityQuote, CreatePoolQuote, RemoveLiquidityQuote, SwapQuote}; + +use crate::{ + quote::{ + self as client_quote, ValidatedFungibleDefinition, ValidatedFungibleHolding, + ValidatedPoolSnapshot, + }, + AmmContext, ClientError, +}; + +/// Denominator used by client slippage tolerances. +/// +/// This aliases the program's canonical basis-point denominator so wire consumers do not maintain +/// a separate numeric convention. +pub const SLIPPAGE_BPS_DENOMINATOR: u128 = FEE_BPS_DENOMINATOR; + +/// Validated price-movement tolerance in basis points. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SlippageTolerance { + bps: u128, +} + +impl SlippageTolerance { + /// Creates a tolerance between zero and 10,000 basis points, inclusive. + pub fn new(bps: u128) -> Result { + if bps > SLIPPAGE_BPS_DENOMINATOR { + return Err(ClientError::SlippageToleranceOutOfRange { + bps, + maximum_bps: SLIPPAGE_BPS_DENOMINATOR, + }); + } + Ok(Self { bps }) + } + + /// Returns the exact basis-point value. + #[must_use] + pub const fn bps(self) -> u128 { + self.bps + } +} + +/// Builds a conservative minimum chain guard with integer floor rounding. +/// +/// Positive quotes are clamped to one raw unit because AMM liquidity instructions reject zero +/// minimums and a one-unit quote has no smaller executable guard. A zero quote remains zero. +pub fn minimum_guard_amount( + quoted_amount: u128, + tolerance: SlippageTolerance, +) -> Result { + let retained_bps = SLIPPAGE_BPS_DENOMINATOR.checked_sub(tolerance.bps).ok_or( + ClientError::SlippageToleranceOutOfRange { + bps: tolerance.bps, + maximum_bps: SLIPPAGE_BPS_DENOMINATOR, + }, + )?; + let guard = checked_mul_div_floor(quoted_amount, retained_bps, SLIPPAGE_BPS_DENOMINATOR) + .ok_or(ClientError::SlippageBoundOverflow { + quoted_amount, + slippage_bps: tolerance.bps, + })?; + + Ok(if quoted_amount == 0 { 0 } else { guard.max(1) }) +} + +/// Builds a conservative maximum chain guard with integer ceil rounding. +pub fn maximum_guard_amount( + quoted_amount: u128, + tolerance: SlippageTolerance, +) -> Result { + let expanded_bps = SLIPPAGE_BPS_DENOMINATOR.checked_add(tolerance.bps).ok_or( + ClientError::SlippageBoundOverflow { + quoted_amount, + slippage_bps: tolerance.bps, + }, + )?; + checked_mul_div_ceil(quoted_amount, expanded_bps, SLIPPAGE_BPS_DENOMINATOR).ok_or( + ClientError::SlippageBoundOverflow { + quoted_amount, + slippage_bps: tolerance.bps, + }, + ) +} + +/// Pool-creation quote plus exact `NewDefinition` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedCreatePool { + pub quote: CreatePoolQuote, + pub token_a_amount: u128, + pub token_b_amount: u128, + pub fees: u128, +} + +/// Add-liquidity quote plus slippage-safe `AddLiquidity` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedAddLiquidity { + pub quote: AddLiquidityQuote, + pub min_amount_liquidity: u128, + pub max_amount_to_add_token_a: u128, + pub max_amount_to_add_token_b: u128, +} + +/// Remove-liquidity quote plus slippage-safe `RemoveLiquidity` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedRemoveLiquidity { + pub quote: RemoveLiquidityQuote, + pub remove_liquidity_amount: u128, + pub min_amount_to_remove_token_a: u128, + pub min_amount_to_remove_token_b: u128, +} + +/// Exact-input quote plus slippage-safe `SwapExactInput` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedSwapExactInput { + pub quote: SwapQuote, + pub swap_amount_in: u128, + pub min_amount_out: u128, +} + +/// Exact-output quote plus slippage-safe `SwapExactOutput` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedSwapExactOutput { + pub quote: SwapQuote, + pub exact_amount_out: u128, + pub max_amount_in: u128, +} + +/// Quotes pool creation and returns the exact instruction amount fields. +pub fn prepare_create_pool( + context: &AmmContext, + token_a: &ValidatedFungibleDefinition, + token_b: &ValidatedFungibleDefinition, + token_a_amount: u128, + token_b_amount: u128, + fee_bps: u128, +) -> Result { + let quote = client_quote::create_pool( + context, + token_a, + token_b, + token_a_amount, + token_b_amount, + fee_bps, + )?; + Ok(PreparedCreatePool { + quote, + token_a_amount: quote.pool.reserve_a, + token_b_amount: quote.pool.reserve_b, + fees: fee_bps, + }) +} + +/// Quotes add liquidity and derives its minimum-LP guard. +pub fn prepare_add_liquidity( + snapshot: &ValidatedPoolSnapshot, + max_amount_a: u128, + max_amount_b: u128, + tolerance: SlippageTolerance, +) -> Result { + let preview = client_quote::preview_add_liquidity(snapshot, max_amount_a, max_amount_b)?; + let min_amount_liquidity = minimum_guard_amount(preview.liquidity_to_mint, tolerance)?; + let max_amount_to_add_token_a = preview.actual_amount_a; + let max_amount_to_add_token_b = preview.actual_amount_b; + let quote = client_quote::add_liquidity( + snapshot, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + min_amount_liquidity, + )?; + + Ok(PreparedAddLiquidity { + quote, + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + }) +} + +/// Quotes remove liquidity and derives both minimum-withdrawal guards. +pub fn prepare_remove_liquidity( + snapshot: &ValidatedPoolSnapshot, + user_liquidity: &ValidatedFungibleHolding, + remove_liquidity_amount: u128, + tolerance: SlippageTolerance, +) -> Result { + let preview = + client_quote::preview_remove_liquidity(snapshot, user_liquidity, remove_liquidity_amount)?; + let min_amount_to_remove_token_a = minimum_guard_amount(preview.withdraw_amount_a, tolerance)?; + let min_amount_to_remove_token_b = minimum_guard_amount(preview.withdraw_amount_b, tolerance)?; + let quote = client_quote::remove_liquidity( + snapshot, + user_liquidity, + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + )?; + + Ok(PreparedRemoveLiquidity { + quote, + remove_liquidity_amount: quote.liquidity_to_burn, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + }) +} + +/// Quotes an exact-input swap and derives its minimum-output guard. +pub fn prepare_swap_exact_input( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + amount_in: u128, + tolerance: SlippageTolerance, +) -> Result { + let preview = + client_quote::preview_swap_exact_input(snapshot, user_input, user_output, amount_in)?; + let min_amount_out = minimum_guard_amount(preview.amount_out, tolerance)?; + let quote = client_quote::swap_exact_input( + snapshot, + user_input, + user_output, + amount_in, + min_amount_out, + )?; + + Ok(PreparedSwapExactInput { + quote, + swap_amount_in: quote.amount_in, + min_amount_out, + }) +} + +/// Quotes an exact-output swap and derives its maximum-input guard. +pub fn prepare_swap_exact_output( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + exact_amount_out: u128, + tolerance: SlippageTolerance, +) -> Result { + let preview = client_quote::preview_swap_exact_output( + snapshot, + user_input, + user_output, + exact_amount_out, + )?; + let max_amount_in = maximum_guard_amount(preview.amount_in, tolerance)?; + let quote = client_quote::swap_exact_output( + snapshot, + user_input, + user_output, + exact_amount_out, + max_amount_in, + )?; + + Ok(PreparedSwapExactOutput { + quote, + exact_amount_out: quote.amount_out, + max_amount_in, + }) +} diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs new file mode 100644 index 00000000..7d65e5c9 --- /dev/null +++ b/programs/amm/client/src/wire.rs @@ -0,0 +1,1350 @@ +//! Lossless JSON transport adapters for the typed AMM client API. + +use std::{error::Error, fmt, str::FromStr}; + +use amm_core::{ + AmmConfig, PoolDefinition, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, SUPPORTED_FEE_TIERS, +}; +use amm_program::quote::{ + AddLiquidityQuote, CreatePoolQuote, OraclePriceAccountQuote, PairOrder, PoolUpdate, + RemoveLiquidityQuote, SwapDirection, SwapQuote, SyncReservesQuote, +}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{ + plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, + plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input, + plan_swap_exact_output, plan_sync_reserves, plan_update_config, + quote::{ + self as client_quote, AccountSnapshot, ValidatedFungibleDefinition, + ValidatedFungibleHolding, ValidatedPoolSnapshot, + }, + AddLiquidityPlanInput, AmmContext, ClientError, CreateOraclePriceAccountPlanInput, + CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, PoolContext, + PreparedAddLiquidity, PreparedCreatePool, PreparedRemoveLiquidity, PreparedSwapExactInput, + PreparedSwapExactOutput, RemoveLiquidityPlanInput, SlippageTolerance, SwapExactInputPlanInput, + SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, + SLIPPAGE_BPS_DENOMINATOR, +}; + +/// Stable transport failure returned by the JSON and C ABI adapters. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WireError { + code: String, + message: String, +} + +impl WireError { + fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + /// Stable machine-readable error code. + #[must_use] + pub fn code(&self) -> &str { + &self.code + } +} + +impl fmt::Display for WireError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for WireError {} + +impl From for WireError { + fn from(error: ClientError) -> Self { + Self::new(error.code(), error.to_string()) + } +} + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum PlanRequest { + Initialize { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + #[serde(rename = "tokenProgramId")] + token_program_id: ProgramId, + #[serde(rename = "twapOracleProgramId")] + twap_oracle_program_id: ProgramId, + authority: String, + }, + UpdateConfig { + context: ContextInput, + #[serde(rename = "tokenProgramId")] + token_program_id: Option, + #[serde(rename = "twapOracleProgramId")] + twap_oracle_program_id: Option, + #[serde(rename = "newAuthority")] + new_authority: Option, + }, + CreatePriceObservations { + context: ContextInput, + #[serde(rename = "poolId")] + pool_id: String, + #[serde(rename = "windowDuration")] + window_duration: String, + }, + CreateOraclePriceAccount { + context: ContextInput, + #[serde(rename = "poolId")] + pool_id: String, + #[serde(rename = "windowDuration")] + window_duration: String, + }, + CreatePool { + context: ContextInput, + #[serde(rename = "tokenADefinitionId")] + token_a_definition_id: String, + #[serde(rename = "tokenBDefinitionId")] + token_b_definition_id: String, + #[serde(rename = "userHoldingA")] + user_holding_a: String, + #[serde(rename = "userHoldingB")] + user_holding_b: String, + #[serde(rename = "userHoldingLp")] + user_holding_lp: String, + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + fees: String, + deadline: String, + }, + AddLiquidity { + context: ContextInput, + pool: PoolInput, + #[serde(rename = "userHoldingA")] + user_holding_a: String, + #[serde(rename = "userHoldingB")] + user_holding_b: String, + #[serde(rename = "userHoldingLp")] + user_holding_lp: String, + #[serde(rename = "minAmountLiquidity")] + min_amount_liquidity: String, + #[serde(rename = "maxAmountToAddTokenA")] + max_amount_to_add_token_a: String, + #[serde(rename = "maxAmountToAddTokenB")] + max_amount_to_add_token_b: String, + deadline: String, + }, + RemoveLiquidity { + context: ContextInput, + pool: PoolInput, + #[serde(rename = "userHoldingA")] + user_holding_a: String, + #[serde(rename = "userHoldingB")] + user_holding_b: String, + #[serde(rename = "userHoldingLp")] + user_holding_lp: String, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + #[serde(rename = "minAmountToRemoveTokenA")] + min_amount_to_remove_token_a: String, + #[serde(rename = "minAmountToRemoveTokenB")] + min_amount_to_remove_token_b: String, + deadline: String, + }, + SwapExactInput { + context: ContextInput, + pool: PoolInput, + #[serde(rename = "userInputHolding")] + user_input_holding: String, + #[serde(rename = "userOutputHolding")] + user_output_holding: String, + #[serde(rename = "swapAmountIn")] + swap_amount_in: String, + #[serde(rename = "minAmountOut")] + min_amount_out: String, + deadline: String, + }, + SwapExactOutput { + context: ContextInput, + pool: PoolInput, + #[serde(rename = "userInputHolding")] + user_input_holding: String, + #[serde(rename = "userOutputHolding")] + user_output_holding: String, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + #[serde(rename = "maxAmountIn")] + max_amount_in: String, + deadline: String, + }, + SyncReserves { + context: ContextInput, + pool: PoolInput, + }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ContextInput { + amm_program_id: ProgramId, + token_program_id: ProgramId, + twap_oracle_program_id: ProgramId, + authority: String, +} + +impl ContextInput { + fn into_context(self) -> Result { + Ok(AmmContext::new( + self.amm_program_id, + AmmConfig { + token_program_id: self.token_program_id, + twap_oracle_program_id: self.twap_oracle_program_id, + authority: account_id(&self.authority, "context.authority")?, + }, + )) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PoolInput { + pool_id: String, + definition_token_a_id: String, + definition_token_b_id: String, + vault_a_id: String, + vault_b_id: String, + liquidity_pool_id: String, + liquidity_pool_supply: String, + reserve_a: String, + reserve_b: String, + fees: String, +} + +impl PoolInput { + fn into_pool(self) -> Result<(AccountId, PoolDefinition), WireError> { + Ok(( + account_id(&self.pool_id, "pool.poolId")?, + PoolDefinition { + definition_token_a_id: account_id( + &self.definition_token_a_id, + "pool.definitionTokenAId", + )?, + definition_token_b_id: account_id( + &self.definition_token_b_id, + "pool.definitionTokenBId", + )?, + vault_a_id: account_id(&self.vault_a_id, "pool.vaultAId")?, + vault_b_id: account_id(&self.vault_b_id, "pool.vaultBId")?, + liquidity_pool_id: account_id(&self.liquidity_pool_id, "pool.liquidityPoolId")?, + liquidity_pool_supply: decimal_u128( + &self.liquidity_pool_supply, + "pool.liquidityPoolSupply", + )?, + reserve_a: decimal_u128(&self.reserve_a, "pool.reserveA")?, + reserve_b: decimal_u128(&self.reserve_b, "pool.reserveB")?, + fees: decimal_u128(&self.fees, "pool.fees")?, + }, + )) + } +} + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum QuoteRequest { + ProtocolConstants, + PairOrder { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + }, + CreatePool { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + #[serde(rename = "tokenADefinition")] + token_a_definition: AccountSnapshotInput, + #[serde(rename = "tokenBDefinition")] + token_b_definition: AccountSnapshotInput, + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + PrepareCreatePool { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + #[serde(rename = "tokenADefinition")] + token_a_definition: AccountSnapshotInput, + #[serde(rename = "tokenBDefinition")] + token_b_definition: AccountSnapshotInput, + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + PreviewAddLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "maxAmountA")] + max_amount_a: String, + #[serde(rename = "maxAmountB")] + max_amount_b: String, + }, + PrepareAddLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "maxAmountA")] + max_amount_a: String, + #[serde(rename = "maxAmountB")] + max_amount_b: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + }, + AddLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "maxAmountA")] + max_amount_a: String, + #[serde(rename = "maxAmountB")] + max_amount_b: String, + #[serde(rename = "minimumLiquidity")] + minimum_liquidity: String, + }, + PreviewRemoveLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userLiquidityHolding")] + user_liquidity_holding: AccountSnapshotInput, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + }, + PrepareRemoveLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userLiquidityHolding")] + user_liquidity_holding: AccountSnapshotInput, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + }, + RemoveLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userLiquidityHolding")] + user_liquidity_holding: AccountSnapshotInput, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + #[serde(rename = "minimumAmountA")] + minimum_amount_a: String, + #[serde(rename = "minimumAmountB")] + minimum_amount_b: String, + }, + PreviewSwapExactInput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "amountIn")] + amount_in: String, + }, + PrepareSwapExactInput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "amountIn")] + amount_in: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + }, + SwapExactInput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "amountIn")] + amount_in: String, + #[serde(rename = "minimumAmountOut")] + minimum_amount_out: String, + }, + PreviewSwapExactOutput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + }, + PrepareSwapExactOutput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + }, + SwapExactOutput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + #[serde(rename = "maximumAmountIn")] + maximum_amount_in: String, + }, + SyncReserves { + #[serde(flatten)] + state: PoolStateInput, + }, + CreateOraclePriceAccount { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "windowDuration")] + window_duration: String, + }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PoolStateInput { + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshot: PoolSnapshotInput, +} + +impl PoolStateInput { + fn validate(self) -> Result<(AmmContext, ValidatedPoolSnapshot), WireError> { + let config = self.config.into_snapshot()?; + let context = AmmContext::from_config_account(self.amm_program_id, &config)?; + let snapshot = self.snapshot.validate(&context)?; + Ok((context, snapshot)) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PoolSnapshotInput { + pool: AccountSnapshotInput, + token_a_definition: AccountSnapshotInput, + token_b_definition: AccountSnapshotInput, + vault_a: AccountSnapshotInput, + vault_b: AccountSnapshotInput, + liquidity_definition: AccountSnapshotInput, +} + +impl PoolSnapshotInput { + fn validate(self, context: &AmmContext) -> Result { + let pool = self.pool.into_snapshot()?; + let token_a_definition = self.token_a_definition.into_snapshot()?; + let token_b_definition = self.token_b_definition.into_snapshot()?; + let vault_a = self.vault_a.into_snapshot()?; + let vault_b = self.vault_b.into_snapshot()?; + let liquidity_definition = self.liquidity_definition.into_snapshot()?; + + Ok(ValidatedPoolSnapshot::new( + context, + &pool, + &token_a_definition, + &token_b_definition, + &vault_a, + &vault_b, + &liquidity_definition, + )?) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AccountSnapshotInput { + id: String, + program_owner: ProgramId, + balance: String, + nonce: String, + data: String, +} + +impl AccountSnapshotInput { + fn into_snapshot(self) -> Result { + let bytes = hex_bytes(&self.data, "account.data")?; + let data = Data::try_from(bytes) + .map_err(|error| invalid_request(format!("account.data is too large: {error}")))?; + Ok(AccountSnapshot::new( + account_id(&self.id, "account.id")?, + Account { + program_owner: self.program_owner, + balance: decimal_u128(&self.balance, "account.balance")?, + data, + nonce: Nonce(decimal_u128(&self.nonce, "account.nonce")?), + }, + )) + } +} + +/// Builds one of the ten canonical transaction plans from tagged JSON. +pub fn plan_json(value: Value) -> Result { + let request: PlanRequest = serde_json::from_value(value) + .map_err(|error| invalid_request(format!("invalid plan request: {error}")))?; + let plan = match request { + PlanRequest::Initialize { + amm_program_id, + token_program_id, + twap_oracle_program_id, + authority, + } => plan_initialize(InitializePlanInput { + amm_program_id, + token_program_id, + twap_oracle_program_id, + authority: account_id(&authority, "authority")?, + }), + PlanRequest::UpdateConfig { + context, + token_program_id, + twap_oracle_program_id, + new_authority, + } => { + let context = context.into_context()?; + let new_authority = new_authority + .as_deref() + .map(|value| account_id(value, "newAuthority")) + .transpose()?; + plan_update_config(UpdateConfigPlanInput { + context: &context, + token_program_id, + twap_oracle_program_id, + new_authority, + }) + } + PlanRequest::CreatePriceObservations { + context, + pool_id, + window_duration, + } => { + let context = context.into_context()?; + plan_create_price_observations(CreatePriceObservationsPlanInput { + context: &context, + pool_id: account_id(&pool_id, "poolId")?, + window_duration: decimal_u64(&window_duration, "windowDuration")?, + }) + } + PlanRequest::CreateOraclePriceAccount { + context, + pool_id, + window_duration, + } => { + let context = context.into_context()?; + plan_create_oracle_price_account(CreateOraclePriceAccountPlanInput { + context: &context, + pool_id: account_id(&pool_id, "poolId")?, + window_duration: decimal_u64(&window_duration, "windowDuration")?, + }) + } + PlanRequest::CreatePool { + context, + token_a_definition_id, + token_b_definition_id, + user_holding_a, + user_holding_b, + user_holding_lp, + token_a_amount, + token_b_amount, + fees, + deadline, + } => { + let context = context.into_context()?; + plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: account_id(&token_a_definition_id, "tokenADefinitionId")?, + token_b_definition_id: account_id(&token_b_definition_id, "tokenBDefinitionId")?, + user_holding_a: account_id(&user_holding_a, "userHoldingA")?, + user_holding_b: account_id(&user_holding_b, "userHoldingB")?, + user_holding_lp: account_id(&user_holding_lp, "userHoldingLp")?, + token_a_amount: decimal_u128(&token_a_amount, "tokenAAmount")?, + token_b_amount: decimal_u128(&token_b_amount, "tokenBAmount")?, + fees: decimal_u128(&fees, "fees")?, + deadline: decimal_u64(&deadline, "deadline")?, + })? + } + PlanRequest::AddLiquidity { + context, + pool, + user_holding_a, + user_holding_b, + user_holding_lp, + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + deadline, + } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_add_liquidity(AddLiquidityPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + user_holding_a: account_id(&user_holding_a, "userHoldingA")?, + user_holding_b: account_id(&user_holding_b, "userHoldingB")?, + user_holding_lp: account_id(&user_holding_lp, "userHoldingLp")?, + min_amount_liquidity: decimal_u128(&min_amount_liquidity, "minAmountLiquidity")?, + max_amount_to_add_token_a: decimal_u128( + &max_amount_to_add_token_a, + "maxAmountToAddTokenA", + )?, + max_amount_to_add_token_b: decimal_u128( + &max_amount_to_add_token_b, + "maxAmountToAddTokenB", + )?, + deadline: decimal_u64(&deadline, "deadline")?, + }) + } + PlanRequest::RemoveLiquidity { + context, + pool, + user_holding_a, + user_holding_b, + user_holding_lp, + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + deadline, + } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + user_holding_a: account_id(&user_holding_a, "userHoldingA")?, + user_holding_b: account_id(&user_holding_b, "userHoldingB")?, + user_holding_lp: account_id(&user_holding_lp, "userHoldingLp")?, + remove_liquidity_amount: decimal_u128( + &remove_liquidity_amount, + "removeLiquidityAmount", + )?, + min_amount_to_remove_token_a: decimal_u128( + &min_amount_to_remove_token_a, + "minAmountToRemoveTokenA", + )?, + min_amount_to_remove_token_b: decimal_u128( + &min_amount_to_remove_token_b, + "minAmountToRemoveTokenB", + )?, + deadline: decimal_u64(&deadline, "deadline")?, + }) + } + PlanRequest::SwapExactInput { + context, + pool, + user_input_holding, + user_output_holding, + swap_amount_in, + min_amount_out, + deadline, + } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_swap_exact_input(SwapExactInputPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + user_input_holding: account_id(&user_input_holding, "userInputHolding")?, + user_output_holding: account_id(&user_output_holding, "userOutputHolding")?, + swap_amount_in: decimal_u128(&swap_amount_in, "swapAmountIn")?, + min_amount_out: decimal_u128(&min_amount_out, "minAmountOut")?, + deadline: decimal_u64(&deadline, "deadline")?, + }) + } + PlanRequest::SwapExactOutput { + context, + pool, + user_input_holding, + user_output_holding, + exact_amount_out, + max_amount_in, + deadline, + } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_swap_exact_output(SwapExactOutputPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + user_input_holding: account_id(&user_input_holding, "userInputHolding")?, + user_output_holding: account_id(&user_output_holding, "userOutputHolding")?, + exact_amount_out: decimal_u128(&exact_amount_out, "exactAmountOut")?, + max_amount_in: decimal_u128(&max_amount_in, "maxAmountIn")?, + deadline: decimal_u64(&deadline, "deadline")?, + }) + } + PlanRequest::SyncReserves { context, pool } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_sync_reserves(SyncReservesPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + }) + } + }; + + transaction_plan_json(&plan) +} + +/// Evaluates one reusable AMM economic quote from tagged JSON. +pub fn quote_json(value: Value) -> Result { + let request: QuoteRequest = serde_json::from_value(value) + .map_err(|error| invalid_request(format!("invalid quote request: {error}")))?; + match request { + QuoteRequest::ProtocolConstants => Ok(json!({ + "minimumLiquidity": MINIMUM_LIQUIDITY.to_string(), + "feeBpsDenominator": FEE_BPS_DENOMINATOR.to_string(), + "slippageBpsDenominator": SLIPPAGE_BPS_DENOMINATOR.to_string(), + "supportedFeeTiers": SUPPORTED_FEE_TIERS + .iter() + .map(u128::to_string) + .collect::>(), + })), + QuoteRequest::PairOrder { + state, + first_token_definition_id, + second_token_definition_id, + } => { + let (_, snapshot) = state.validate()?; + let first_id = account_id(&first_token_definition_id, "firstTokenDefinitionId")?; + let second_id = account_id(&second_token_definition_id, "secondTokenDefinitionId")?; + let first = pool_definition(&snapshot, first_id, "firstTokenDefinitionId")?; + let second = pool_definition(&snapshot, second_id, "secondTokenDefinitionId")?; + let order = client_quote::pair_order(&snapshot, first, second)?; + Ok(json!({ + "order": match order { + PairOrder::Stored => "stored", + PairOrder::Reversed => "reversed", + }, + })) + } + QuoteRequest::CreatePool { + amm_program_id, + config, + token_a_definition, + token_b_definition, + token_a_amount, + token_b_amount, + fee_bps, + } => { + let config = config.into_snapshot()?; + let context = AmmContext::from_config_account(amm_program_id, &config)?; + let token_a_definition = token_a_definition.into_snapshot()?; + let token_b_definition = token_b_definition.into_snapshot()?; + let token_a = ValidatedFungibleDefinition::new(&context, &token_a_definition)?; + let token_b = ValidatedFungibleDefinition::new(&context, &token_b_definition)?; + let quote = client_quote::create_pool( + &context, + &token_a, + &token_b, + decimal_u128(&token_a_amount, "tokenAAmount")?, + decimal_u128(&token_b_amount, "tokenBAmount")?, + decimal_u128(&fee_bps, "feeBps")?, + )?; + Ok(create_pool_quote_json(quote)) + } + QuoteRequest::PrepareCreatePool { + amm_program_id, + config, + token_a_definition, + token_b_definition, + token_a_amount, + token_b_amount, + fee_bps, + } => { + let config = config.into_snapshot()?; + let context = AmmContext::from_config_account(amm_program_id, &config)?; + let token_a_definition = token_a_definition.into_snapshot()?; + let token_b_definition = token_b_definition.into_snapshot()?; + let token_a = ValidatedFungibleDefinition::new(&context, &token_a_definition)?; + let token_b = ValidatedFungibleDefinition::new(&context, &token_b_definition)?; + let prepared = crate::prepare_create_pool( + &context, + &token_a, + &token_b, + decimal_u128(&token_a_amount, "tokenAAmount")?, + decimal_u128(&token_b_amount, "tokenBAmount")?, + decimal_u128(&fee_bps, "feeBps")?, + )?; + Ok(prepared_create_pool_json(prepared)) + } + QuoteRequest::PreviewAddLiquidity { + state, + max_amount_a, + max_amount_b, + } => { + let (_, snapshot) = state.validate()?; + let quote = client_quote::preview_add_liquidity( + &snapshot, + decimal_u128(&max_amount_a, "maxAmountA")?, + decimal_u128(&max_amount_b, "maxAmountB")?, + )?; + Ok(add_liquidity_quote_json(quote)) + } + QuoteRequest::PrepareAddLiquidity { + state, + max_amount_a, + max_amount_b, + slippage_bps, + } => { + let (_, snapshot) = state.validate()?; + let prepared = crate::prepare_add_liquidity( + &snapshot, + decimal_u128(&max_amount_a, "maxAmountA")?, + decimal_u128(&max_amount_b, "maxAmountB")?, + slippage_tolerance(&slippage_bps)?, + )?; + Ok(prepared_add_liquidity_json(prepared)) + } + QuoteRequest::AddLiquidity { + state, + max_amount_a, + max_amount_b, + minimum_liquidity, + } => { + let (_, snapshot) = state.validate()?; + let quote = client_quote::add_liquidity( + &snapshot, + decimal_u128(&max_amount_a, "maxAmountA")?, + decimal_u128(&max_amount_b, "maxAmountB")?, + decimal_u128(&minimum_liquidity, "minimumLiquidity")?, + )?; + Ok(add_liquidity_quote_json(quote)) + } + QuoteRequest::PreviewRemoveLiquidity { + state, + user_liquidity_holding, + remove_liquidity_amount, + } => { + let (context, snapshot) = state.validate()?; + let user_liquidity = validated_holding( + &context, + user_liquidity_holding, + snapshot.liquidity_definition(), + )?; + let quote = client_quote::preview_remove_liquidity( + &snapshot, + &user_liquidity, + decimal_u128(&remove_liquidity_amount, "removeLiquidityAmount")?, + )?; + Ok(remove_liquidity_quote_json(quote)) + } + QuoteRequest::PrepareRemoveLiquidity { + state, + user_liquidity_holding, + remove_liquidity_amount, + slippage_bps, + } => { + let (context, snapshot) = state.validate()?; + let user_liquidity = validated_holding( + &context, + user_liquidity_holding, + snapshot.liquidity_definition(), + )?; + let prepared = crate::prepare_remove_liquidity( + &snapshot, + &user_liquidity, + decimal_u128(&remove_liquidity_amount, "removeLiquidityAmount")?, + slippage_tolerance(&slippage_bps)?, + )?; + Ok(prepared_remove_liquidity_json(prepared)) + } + QuoteRequest::RemoveLiquidity { + state, + user_liquidity_holding, + remove_liquidity_amount, + minimum_amount_a, + minimum_amount_b, + } => { + let (context, snapshot) = state.validate()?; + let user_liquidity = validated_holding( + &context, + user_liquidity_holding, + snapshot.liquidity_definition(), + )?; + let quote = client_quote::remove_liquidity( + &snapshot, + &user_liquidity, + decimal_u128(&remove_liquidity_amount, "removeLiquidityAmount")?, + decimal_u128(&minimum_amount_a, "minimumAmountA")?, + decimal_u128(&minimum_amount_b, "minimumAmountB")?, + )?; + Ok(remove_liquidity_quote_json(quote)) + } + QuoteRequest::PreviewSwapExactInput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + amount_in, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let quote = client_quote::preview_swap_exact_input( + &snapshot, + &user_input, + &user_output, + decimal_u128(&amount_in, "amountIn")?, + )?; + Ok(swap_quote_json(quote)) + } + QuoteRequest::PrepareSwapExactInput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + amount_in, + slippage_bps, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let prepared = crate::prepare_swap_exact_input( + &snapshot, + &user_input, + &user_output, + decimal_u128(&amount_in, "amountIn")?, + slippage_tolerance(&slippage_bps)?, + )?; + Ok(prepared_swap_exact_input_json(prepared)) + } + QuoteRequest::SwapExactInput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + amount_in, + minimum_amount_out, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let quote = client_quote::swap_exact_input( + &snapshot, + &user_input, + &user_output, + decimal_u128(&amount_in, "amountIn")?, + decimal_u128(&minimum_amount_out, "minimumAmountOut")?, + )?; + Ok(swap_quote_json(quote)) + } + QuoteRequest::PreviewSwapExactOutput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + exact_amount_out, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let quote = client_quote::preview_swap_exact_output( + &snapshot, + &user_input, + &user_output, + decimal_u128(&exact_amount_out, "exactAmountOut")?, + )?; + Ok(swap_quote_json(quote)) + } + QuoteRequest::PrepareSwapExactOutput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + exact_amount_out, + slippage_bps, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let prepared = crate::prepare_swap_exact_output( + &snapshot, + &user_input, + &user_output, + decimal_u128(&exact_amount_out, "exactAmountOut")?, + slippage_tolerance(&slippage_bps)?, + )?; + Ok(prepared_swap_exact_output_json(prepared)) + } + QuoteRequest::SwapExactOutput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + exact_amount_out, + maximum_amount_in, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let quote = client_quote::swap_exact_output( + &snapshot, + &user_input, + &user_output, + decimal_u128(&exact_amount_out, "exactAmountOut")?, + decimal_u128(&maximum_amount_in, "maximumAmountIn")?, + )?; + Ok(swap_quote_json(quote)) + } + QuoteRequest::SyncReserves { state } => { + let (_, snapshot) = state.validate()?; + Ok(sync_reserves_quote_json(client_quote::sync_reserves( + &snapshot, + )?)) + } + QuoteRequest::CreateOraclePriceAccount { + state, + window_duration, + } => { + let (_, snapshot) = state.validate()?; + Ok(oracle_price_quote_json( + client_quote::create_oracle_price_account( + &snapshot, + decimal_u64(&window_duration, "windowDuration")?, + )?, + )) + } + } +} + +fn transaction_plan_json(plan: &TransactionPlan) -> Result { + let instruction_words = plan.instruction_data().map_err(|error| { + WireError::new( + "instruction_encoding_failed", + format!("instruction serialization failed: {error}"), + ) + })?; + let accounts = plan + .accounts() + .iter() + .map(|account| { + json!({ + "id": account.id().to_string(), + "role": account.role().as_str(), + "writable": account.writable(), + "signer": account.signer(), + "init": account.init(), + }) + }) + .collect::>(); + + Ok(json!({ + "instruction": plan.instruction_name(), + "programId": plan.program_id(), + "accounts": accounts, + "instructionWords": instruction_words, + })) +} + +fn pool_definition<'a>( + snapshot: &'a ValidatedPoolSnapshot, + definition_id: AccountId, + field: &str, +) -> Result<&'a ValidatedFungibleDefinition, WireError> { + if snapshot.token_a_definition().account_id() == definition_id { + Ok(snapshot.token_a_definition()) + } else if snapshot.token_b_definition().account_id() == definition_id { + Ok(snapshot.token_b_definition()) + } else { + Err(invalid_request(format!( + "{field} is not one of the pool token definitions" + ))) + } +} + +fn validated_holding( + context: &AmmContext, + holding: AccountSnapshotInput, + definition: &ValidatedFungibleDefinition, +) -> Result { + let holding = holding.into_snapshot()?; + Ok(ValidatedFungibleHolding::new( + context, &holding, definition, + )?) +} + +fn validated_swap_holdings( + context: &AmmContext, + snapshot: &ValidatedPoolSnapshot, + input_holding: AccountSnapshotInput, + output_holding: AccountSnapshotInput, + definition_id: &str, +) -> Result<(ValidatedFungibleHolding, ValidatedFungibleHolding), WireError> { + let definition_id = account_id(definition_id, "inputTokenDefinitionId")?; + let input_definition = pool_definition(snapshot, definition_id, "inputTokenDefinitionId")?; + let output_definition = + if input_definition.account_id() == snapshot.token_a_definition().account_id() { + snapshot.token_b_definition() + } else { + snapshot.token_a_definition() + }; + Ok(( + validated_holding(context, input_holding, input_definition)?, + validated_holding(context, output_holding, output_definition)?, + )) +} + +fn pool_update_json(pool: PoolUpdate) -> Value { + json!({ + "liquidityPoolSupply": pool.liquidity_pool_supply.to_string(), + "reserveA": pool.reserve_a.to_string(), + "reserveB": pool.reserve_b.to_string(), + "spotPriceQ64_64": pool.spot_price_q64_64.to_string(), + }) +} + +fn create_pool_quote_json(quote: CreatePoolQuote) -> Value { + json!({ + "pool": pool_update_json(quote.pool), + "lockedLiquidity": quote.locked_liquidity.to_string(), + "userLiquidity": quote.user_liquidity.to_string(), + }) +} + +fn prepared_create_pool_json(prepared: PreparedCreatePool) -> Value { + json!({ + "quote": create_pool_quote_json(prepared.quote), + "instructionArgs": { + "tokenAAmount": prepared.token_a_amount.to_string(), + "tokenBAmount": prepared.token_b_amount.to_string(), + "fees": prepared.fees.to_string(), + }, + }) +} + +fn add_liquidity_quote_json(quote: AddLiquidityQuote) -> Value { + json!({ + "actualAmountA": quote.actual_amount_a.to_string(), + "actualAmountB": quote.actual_amount_b.to_string(), + "liquidityToMint": quote.liquidity_to_mint.to_string(), + "pool": pool_update_json(quote.pool), + }) +} + +fn prepared_add_liquidity_json(prepared: PreparedAddLiquidity) -> Value { + json!({ + "quote": add_liquidity_quote_json(prepared.quote), + "instructionArgs": { + "minAmountLiquidity": prepared.min_amount_liquidity.to_string(), + "maxAmountToAddTokenA": prepared.max_amount_to_add_token_a.to_string(), + "maxAmountToAddTokenB": prepared.max_amount_to_add_token_b.to_string(), + }, + }) +} + +fn remove_liquidity_quote_json(quote: RemoveLiquidityQuote) -> Value { + json!({ + "withdrawAmountA": quote.withdraw_amount_a.to_string(), + "withdrawAmountB": quote.withdraw_amount_b.to_string(), + "liquidityToBurn": quote.liquidity_to_burn.to_string(), + "pool": pool_update_json(quote.pool), + }) +} + +fn prepared_remove_liquidity_json(prepared: PreparedRemoveLiquidity) -> Value { + json!({ + "quote": remove_liquidity_quote_json(prepared.quote), + "instructionArgs": { + "removeLiquidityAmount": prepared.remove_liquidity_amount.to_string(), + "minAmountToRemoveTokenA": prepared.min_amount_to_remove_token_a.to_string(), + "minAmountToRemoveTokenB": prepared.min_amount_to_remove_token_b.to_string(), + }, + }) +} + +fn swap_quote_json(quote: SwapQuote) -> Value { + json!({ + "direction": match quote.direction { + SwapDirection::AToB => "a_to_b", + SwapDirection::BToA => "b_to_a", + }, + "amountIn": quote.amount_in.to_string(), + "effectiveAmountIn": quote.effective_amount_in.to_string(), + "feeAmount": quote.fee_amount.to_string(), + "amountOut": quote.amount_out.to_string(), + "pool": pool_update_json(quote.pool), + }) +} + +fn prepared_swap_exact_input_json(prepared: PreparedSwapExactInput) -> Value { + json!({ + "quote": swap_quote_json(prepared.quote), + "instructionArgs": { + "swapAmountIn": prepared.swap_amount_in.to_string(), + "minAmountOut": prepared.min_amount_out.to_string(), + }, + }) +} + +fn prepared_swap_exact_output_json(prepared: PreparedSwapExactOutput) -> Value { + json!({ + "quote": swap_quote_json(prepared.quote), + "instructionArgs": { + "exactAmountOut": prepared.exact_amount_out.to_string(), + "maxAmountIn": prepared.max_amount_in.to_string(), + }, + }) +} + +fn sync_reserves_quote_json(quote: SyncReservesQuote) -> Value { + json!({ + "donatedAmountA": quote.donated_amount_a.to_string(), + "donatedAmountB": quote.donated_amount_b.to_string(), + "pool": pool_update_json(quote.pool), + }) +} + +fn oracle_price_quote_json(quote: OraclePriceAccountQuote) -> Value { + json!({ + "baseAsset": quote.base_asset.to_string(), + "quoteAsset": quote.quote_asset.to_string(), + "initialPriceQ64_64": quote.initial_price_q64_64.to_string(), + "windowDuration": quote.window_duration.to_string(), + }) +} + +fn account_id(value: &str, field: &str) -> Result { + AccountId::from_str(value) + .map_err(|error| invalid_request(format!("{field} is not a valid account ID: {error}"))) +} + +fn decimal_u128(value: &str, field: &str) -> Result { + decimal(value, field) +} + +fn decimal_u64(value: &str, field: &str) -> Result { + decimal(value, field) +} + +fn slippage_tolerance(value: &str) -> Result { + Ok(SlippageTolerance::new(decimal_u128(value, "slippageBps")?)?) +} + +fn decimal(value: &str, field: &str) -> Result +where + T: FromStr, +{ + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid_request(format!( + "{field} must be a non-empty unsigned decimal string" + ))); + } + value.parse().map_err(|_| { + invalid_request(format!( + "{field} is outside the supported unsigned integer range" + )) + }) +} + +fn hex_bytes(value: &str, field: &str) -> Result, WireError> { + let mut bytes = Vec::new(); + let mut chunks = value.as_bytes().chunks_exact(2); + for chunk in &mut chunks { + let Some(high) = chunk.first().and_then(|byte| hex_nibble(*byte)) else { + return Err(invalid_request(format!( + "{field} must be an even-length hexadecimal string" + ))); + }; + let Some(low) = chunk.get(1).and_then(|byte| hex_nibble(*byte)) else { + return Err(invalid_request(format!( + "{field} must be an even-length hexadecimal string" + ))); + }; + bytes.push((high << 4) | low); + } + if !chunks.remainder().is_empty() { + return Err(invalid_request(format!( + "{field} must be an even-length hexadecimal string" + ))); + } + Ok(bytes) +} + +const fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => byte.checked_sub(b'0'), + b'a'..=b'f' => match byte.checked_sub(b'a') { + Some(value) => value.checked_add(10), + None => None, + }, + b'A'..=b'F' => match byte.checked_sub(b'A') { + Some(value) => value.checked_add(10), + None => None, + }, + _ => None, + } +} + +fn invalid_request(message: impl Into) -> WireError { + WireError::new("invalid_request", message) +} diff --git a/programs/amm/client/tests/ffi_contract.rs b/programs/amm/client/tests/ffi_contract.rs new file mode 100644 index 00000000..ec7d0940 --- /dev/null +++ b/programs/amm/client/tests/ffi_contract.rs @@ -0,0 +1,356 @@ +#![allow( + unsafe_code, + reason = "contract tests call the exported C ABI and release its owned pointers" +)] + +use std::ffi::{c_char, CStr, CString}; + +use amm_client::{amm_client_free, amm_client_plan, amm_client_quote}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, + AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, +}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; + +type Operation = unsafe extern "C" fn(*const c_char) -> *mut c_char; + +fn call(operation: Operation, request: Option<&CStr>) -> Value { + let request = request.map_or(std::ptr::null(), CStr::as_ptr); + // SAFETY: `request` is null or points into the borrowed `CStr`, which remains live through the + // call. The returned pointer is checked and released exactly once below. + let response = unsafe { operation(request) }; + assert!(!response.is_null()); + + // SAFETY: A non-null response is a live NUL-terminated string owned by the AMM client until + // `amm_client_free` below. + let text = unsafe { CStr::from_ptr(response) } + .to_str() + .expect("FFI response must be UTF-8"); + let value = serde_json::from_str(text).expect("FFI response must be JSON"); + // SAFETY: `response` came from this library and has not been released yet. + unsafe { amm_client_free(response) }; + value +} + +fn call_json(operation: Operation, request: &Value) -> Value { + let request = CString::new(request.to_string()).expect("JSON has no interior NUL"); + call(operation, Some(&request)) +} + +fn snapshot(id: AccountId, account: &Account) -> Value { + json!({ + "id": id.to_string(), + "programOwner": account.program_owner, + "balance": account.balance.to_string(), + "nonce": account.nonce.0.to_string(), + "data": hex(account.data.as_ref()), + }) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn fungible_definition( + program_owner: ProgramId, + total_supply: u128, + authority: Option, +) -> Account { + account( + program_owner, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ) +} + +fn fungible_holding(program_owner: ProgramId, definition_id: AccountId, balance: u128) -> Account { + account( + program_owner, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ) +} + +#[test] +fn null_request_returns_structured_error() { + let response = call(amm_client_plan, None); + + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "null_request"); +} + +#[test] +fn malformed_json_returns_structured_error() { + let request = CString::new("{").expect("literal has no NUL"); + let response = call(amm_client_quote, Some(&request)); + + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_json"); +} + +#[test] +fn invalid_utf8_returns_structured_error() { + let request = CStr::from_bytes_with_nul(&[0xff, 0]).expect("bytes are NUL-terminated"); + let response = call(amm_client_quote, Some(request)); + + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_utf8"); +} + +#[test] +fn free_accepts_null() { + // SAFETY: Null is explicitly accepted by the deallocator contract. + unsafe { amm_client_free(std::ptr::null_mut()) }; +} + +#[test] +fn protocol_constants_are_exposed_without_numeric_json_values() { + let response = call_json( + amm_client_quote, + &json!({"operation": "protocol_constants"}), + ); + + assert_eq!(response["ok"], true); + assert_eq!( + response["value"]["minimumLiquidity"], + MINIMUM_LIQUIDITY.to_string() + ); + assert_eq!(response["value"]["feeBpsDenominator"], "10000"); + assert_eq!(response["value"]["slippageBpsDenominator"], "10000"); + assert_eq!( + response["value"]["supportedFeeTiers"], + json!(["1", "5", "30", "100"]) + ); +} + +#[test] +fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() { + let amm_program_id: ProgramId = [11; 8]; + let token_program_id: ProgramId = [22; 8]; + let twap_oracle_program_id: ProgramId = [33; 8]; + let authority = AccountId::new([44; 32]); + let pool_id = AccountId::new([55; 32]); + let window_duration = 9_007_199_254_740_993_u64; + let response = call_json( + amm_client_plan, + &json!({ + "operation": "create_price_observations", + "context": { + "ammProgramId": amm_program_id, + "tokenProgramId": token_program_id, + "twapOracleProgramId": twap_oracle_program_id, + "authority": authority.to_string(), + }, + "poolId": pool_id.to_string(), + "windowDuration": window_duration.to_string(), + }), + ); + + assert_eq!(response["ok"], true); + assert_eq!( + response["value"]["instruction"], + "create_price_observations" + ); + assert_eq!(response["value"]["programId"], json!(amm_program_id)); + assert!(response["value"]["accounts"].is_array()); + let words: Vec = serde_json::from_value(response["value"]["instructionWords"].clone()) + .expect("instruction words must be u32 JSON numbers"); + let instruction: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("guest codec must decode plan words"); + match instruction { + Instruction::CreatePriceObservations { + window_duration: decoded, + } => assert_eq!(decoded, window_duration), + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::AddLiquidity { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => panic!("expected CreatePriceObservations"), + } +} + +#[test] +fn successful_quote_preserves_u128_above_javascript_range_as_decimal() { + let amm_program_id: ProgramId = [11; 8]; + let token_program_id: ProgramId = [22; 8]; + let twap_oracle_program_id: ProgramId = [33; 8]; + let authority = AccountId::new([44; 32]); + let config = AmmConfig { + token_program_id, + twap_oracle_program_id, + authority, + }; + let config_account = Account { + program_owner: amm_program_id, + balance: 0, + data: Data::from(&config), + nonce: Nonce(0), + }; + let definition = |name: &str| Account { + program_owner: token_program_id, + balance: 0, + data: Data::from(&TokenDefinition::Fungible { + name: String::from(name), + total_supply: 0, + metadata_id: None, + authority: None, + }), + nonce: Nonce(0), + }; + let token_a_id = AccountId::new([61; 32]); + let token_b_id = AccountId::new([62; 32]); + let amount = 9_007_199_254_740_993_u128; + let response = call_json( + amm_client_quote, + &json!({ + "operation": "create_pool", + "ammProgramId": amm_program_id, + "config": snapshot(compute_config_pda(amm_program_id), &config_account), + "tokenADefinition": snapshot(token_a_id, &definition("A")), + "tokenBDefinition": snapshot(token_b_id, &definition("B")), + "tokenAAmount": amount.to_string(), + "tokenBAmount": amount.to_string(), + "feeBps": "30", + }), + ); + + assert_eq!(response["ok"], true); + assert_eq!(response["value"]["pool"]["reserveA"], amount.to_string()); + assert_eq!(response["value"]["pool"]["reserveB"], amount.to_string()); + assert_eq!( + response["value"]["userLiquidity"], + amount + .checked_sub(MINIMUM_LIQUIDITY) + .expect("test amount exceeds liquidity lock") + .to_string() + ); + assert!(response["value"]["pool"]["reserveA"].is_string()); + + let prepared = call_json( + amm_client_quote, + &json!({ + "operation": "prepare_create_pool", + "ammProgramId": amm_program_id, + "config": snapshot(compute_config_pda(amm_program_id), &config_account), + "tokenADefinition": snapshot(token_a_id, &definition("A")), + "tokenBDefinition": snapshot(token_b_id, &definition("B")), + "tokenAAmount": amount.to_string(), + "tokenBAmount": amount.to_string(), + "feeBps": "30", + }), + ); + assert_eq!(prepared["ok"], true); + assert_eq!( + prepared["value"]["instructionArgs"]["tokenAAmount"], + amount.to_string() + ); + assert_eq!( + prepared["value"]["instructionArgs"]["tokenBAmount"], + amount.to_string() + ); + assert!(prepared["value"]["instructionArgs"]["tokenAAmount"].is_string()); +} + +#[test] +fn swap_quote_rejects_unrelated_output_holding() { + let amm_program_id: ProgramId = [11; 8]; + let token_program_id: ProgramId = [22; 8]; + let twap_oracle_program_id: ProgramId = [33; 8]; + let token_a_id = AccountId::new([1; 32]); + let token_b_id = AccountId::new([2; 32]); + let unrelated_token_id = AccountId::new([3; 32]); + let pool_id = compute_pool_pda(amm_program_id, token_a_id, token_b_id); + let vault_a_id = compute_vault_pda(amm_program_id, pool_id, token_a_id); + let vault_b_id = compute_vault_pda(amm_program_id, pool_id, token_b_id); + let liquidity_id = compute_liquidity_token_pda(amm_program_id, pool_id); + let config = AmmConfig { + token_program_id, + twap_oracle_program_id, + authority: AccountId::new([9; 32]), + }; + let pool = PoolDefinition { + definition_token_a_id: token_a_id, + definition_token_b_id: token_b_id, + vault_a_id, + vault_b_id, + liquidity_pool_id: liquidity_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + let response = call_json( + amm_client_quote, + &json!({ + "operation": "preview_swap_exact_input", + "ammProgramId": amm_program_id, + "config": snapshot( + compute_config_pda(amm_program_id), + &account(amm_program_id, Data::from(&config)), + ), + "snapshot": { + "pool": snapshot( + pool_id, + &account(amm_program_id, Data::from(&pool)), + ), + "tokenADefinition": snapshot( + token_a_id, + &fungible_definition(token_program_id, 100_000, None), + ), + "tokenBDefinition": snapshot( + token_b_id, + &fungible_definition(token_program_id, 100_000, None), + ), + "vaultA": snapshot( + vault_a_id, + &fungible_holding(token_program_id, token_a_id, 1_100), + ), + "vaultB": snapshot( + vault_b_id, + &fungible_holding(token_program_id, token_b_id, 550), + ), + "liquidityDefinition": snapshot( + liquidity_id, + &fungible_definition(token_program_id, 2_000, Some(liquidity_id)), + ), + }, + "userInputHolding": snapshot( + AccountId::new([20; 32]), + &fungible_holding(token_program_id, token_a_id, 1_000), + ), + "userOutputHolding": snapshot( + AccountId::new([21; 32]), + &fungible_holding(token_program_id, unrelated_token_id, 0), + ), + "inputTokenDefinitionId": token_a_id.to_string(), + "amountIn": "100", + }), + ); + + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "token_definition_mismatch"); +} diff --git a/programs/amm/client/tests/plan_contract.rs b/programs/amm/client/tests/plan_contract.rs new file mode 100644 index 00000000..ec52b5b9 --- /dev/null +++ b/programs/amm/client/tests/plan_contract.rs @@ -0,0 +1,636 @@ +use amm_client::{ + encode_instruction, plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, + plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input, + plan_swap_exact_output, plan_sync_reserves, plan_update_config, AccountRole, + AddLiquidityPlanInput, AmmContext, ClientError, CreateOraclePriceAccountPlanInput, + CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, PoolContext, + RemoveLiquidityPlanInput, SwapExactInputPlanInput, SwapExactOutputPlanInput, + SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, +}; +use amm_core::{AmmConfig, Instruction, PoolDefinition}; +use amm_program::quote as program_quote; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use nssa_core::{account::AccountId, program::ProgramId}; +use serde_json::Value; +use twap_oracle_core::{ + compute_current_tick_account_pda, compute_oracle_price_account_pda, + compute_price_observations_pda, +}; + +const LARGE_EXACT_INTEGER: u128 = 9_007_199_254_740_993; +const WINDOW_DURATION: u64 = 86_400_000; + +fn account(byte: u8) -> AccountId { + AccountId::new([byte; 32]) +} + +const fn program(word: u32) -> ProgramId { + [word; 8] +} + +fn context() -> AmmContext { + AmmContext::new( + program(42), + AmmConfig { + token_program_id: program(15), + twap_oracle_program_id: program(77), + authority: account(9), + }, + ) +} + +fn pool_fixture(context: &AmmContext) -> (AccountId, PoolDefinition) { + let definition_a = account(3); + let definition_b = account(4); + let pool_id = amm_core::compute_pool_pda(context.amm_program_id, definition_a, definition_b); + ( + pool_id, + PoolDefinition { + definition_token_a_id: definition_a, + definition_token_b_id: definition_b, + vault_a_id: amm_core::compute_vault_pda(context.amm_program_id, pool_id, definition_a), + vault_b_id: amm_core::compute_vault_pda(context.amm_program_id, pool_id, definition_b), + liquidity_pool_id: amm_core::compute_liquidity_token_pda( + context.amm_program_id, + pool_id, + ), + liquidity_pool_supply: 10_000, + reserve_a: 20_000, + reserve_b: 30_000, + fees: amm_core::FEE_TIER_BPS_30, + }, + ) +} + +fn all_plans() -> Vec { + let context = context(); + let (pool_id, pool) = pool_fixture(&context); + let pool = PoolContext::new(&context, pool_id, &pool).expect("valid pool fixture"); + + vec![ + plan_initialize(InitializePlanInput { + amm_program_id: context.amm_program_id, + token_program_id: context.token_program_id(), + twap_oracle_program_id: context.twap_oracle_program_id(), + authority: context.config.authority, + }), + plan_update_config(UpdateConfigPlanInput { + context: &context, + token_program_id: Some(program(16)), + twap_oracle_program_id: Some(program(78)), + new_authority: Some(account(10)), + }), + plan_create_price_observations(CreatePriceObservationsPlanInput { + context: &context, + pool_id, + window_duration: WINDOW_DURATION, + }), + plan_create_oracle_price_account(CreateOraclePriceAccountPlanInput { + context: &context, + pool_id, + window_duration: WINDOW_DURATION, + }), + plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: account(3), + token_b_definition_id: account(4), + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + token_a_amount: 20_000, + token_b_amount: 30_000, + fees: amm_core::FEE_TIER_BPS_30, + deadline: u64::MAX, + }) + .expect("distinct pool definitions"), + plan_add_liquidity(AddLiquidityPlanInput { + context: &context, + pool, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + min_amount_liquidity: 1, + max_amount_to_add_token_a: 200, + max_amount_to_add_token_b: 300, + deadline: u64::MAX, + }), + plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &context, + pool, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + remove_liquidity_amount: 100, + min_amount_to_remove_token_a: 1, + min_amount_to_remove_token_b: 1, + deadline: u64::MAX, + }), + plan_swap_exact_input(SwapExactInputPlanInput { + context: &context, + pool, + user_input_holding: account(31), + user_output_holding: account(32), + swap_amount_in: LARGE_EXACT_INTEGER, + min_amount_out: 1, + deadline: u64::MAX, + }), + plan_swap_exact_output(SwapExactOutputPlanInput { + context: &context, + pool, + user_input_holding: account(32), + user_output_holding: account(31), + exact_amount_out: 10, + max_amount_in: LARGE_EXACT_INTEGER, + deadline: u64::MAX, + }), + plan_sync_reserves(SyncReservesPlanInput { + context: &context, + pool, + }), + ] +} + +#[test] +fn every_instruction_round_trips_through_guest_codec() { + let expected_indices = [0_u32, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + let plans = all_plans(); + assert_eq!(plans.len(), expected_indices.len()); + + for (plan, expected_index) in plans.iter().zip(expected_indices) { + let words = plan.instruction_data().expect("instruction must serialize"); + assert_eq!( + words, + encode_instruction(plan.instruction()).expect("direct encoding") + ); + assert_eq!(words.first().copied(), Some(expected_index)); + + let decoded: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("guest codec must decode words"); + assert_eq!(variant_index(&decoded), expected_index); + assert_eq!( + encode_instruction(&decoded).expect("decoded instruction must serialize"), + words + ); + } +} + +#[test] +fn update_config_none_options_round_trip() { + let instruction = Instruction::UpdateConfig { + token_program_id: None, + twap_oracle_program_id: None, + new_authority: None, + }; + let words = encode_instruction(&instruction).expect("instruction must serialize"); + let decoded: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("instruction must deserialize"); + + assert!(matches!( + decoded, + Instruction::UpdateConfig { + token_program_id: None, + twap_oracle_program_id: None, + new_authority: None, + } + )); +} + +#[test] +fn u128_above_javascript_integer_range_is_exact() { + let instruction = Instruction::SwapExactInput { + swap_amount_in: LARGE_EXACT_INTEGER, + min_amount_out: LARGE_EXACT_INTEGER, + deadline: u64::MAX, + }; + let words = encode_instruction(&instruction).expect("instruction must serialize"); + let decoded: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("instruction must deserialize"); + + let Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + deadline, + } = decoded + else { + panic!("decoded wrong instruction variant"); + }; + assert_eq!(swap_amount_in, LARGE_EXACT_INTEGER); + assert_eq!(min_amount_out, LARGE_EXACT_INTEGER); + assert_eq!(deadline, u64::MAX); +} + +#[test] +fn planner_account_contract_matches_checked_in_idl() { + let idl: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../artifacts/amm-idl.json" + ))) + .expect("checked-in AMM IDL must be JSON"); + assert_eq!( + idl.get("instruction_type").and_then(Value::as_str), + Some("amm_core::Instruction") + ); + let idl_instructions = idl + .get("instructions") + .and_then(Value::as_array) + .expect("IDL instructions array"); + let plans = all_plans(); + assert_eq!(idl_instructions.len(), plans.len()); + + for (idl_instruction, plan) in idl_instructions.iter().zip(plans.iter()) { + assert_eq!( + string_field(idl_instruction, "name"), + plan.instruction_name() + ); + let idl_accounts = idl_instruction + .get("accounts") + .and_then(Value::as_array) + .expect("IDL accounts array"); + assert_eq!(idl_accounts.len(), plan.accounts().len()); + + for (idl_account, planned_account) in idl_accounts.iter().zip(plan.accounts()) { + assert_eq!( + string_field(idl_account, "name"), + planned_account.role().as_str() + ); + assert_eq!( + bool_field(idl_account, "writable"), + planned_account.writable() + ); + assert_eq!(bool_field(idl_account, "signer"), planned_account.signer()); + assert_eq!(bool_field(idl_account, "init"), planned_account.init()); + } + } +} + +#[test] +fn signer_sets_follow_guest_account_order() { + let plans = all_plans(); + let expected = vec![ + vec![], + vec![account(9)], + vec![], + vec![], + vec![account(31), account(32), account(33)], + vec![account(31), account(32)], + vec![account(33)], + vec![account(31)], + vec![account(32)], + vec![], + ]; + assert_eq!(plans.len(), expected.len()); + + for (plan, expected_signers) in plans.iter().zip(expected) { + assert_eq!(plan.signer_account_ids(), expected_signers); + } +} + +#[test] +fn account_ids_and_signer_flags_stay_positionally_aligned() { + for plan in all_plans() { + let account_ids = plan.account_ids(); + let signer_flags = plan.signer_flags(); + assert_eq!(account_ids.len(), signer_flags.len()); + + let filtered_ids: Vec = account_ids + .into_iter() + .zip(signer_flags) + .filter_map(|(account_id, signer)| signer.then_some(account_id)) + .collect(); + assert_eq!(filtered_ids, plan.signer_account_ids()); + } +} + +#[test] +fn quote_results_feed_instruction_amounts_and_guards_without_recalculation() { + let context = context(); + let (pool_id, pool_definition) = pool_fixture(&context); + let pool = PoolContext::new(&context, pool_id, &pool_definition).expect("valid pool fixture"); + + let create_quote = + program_quote::create_pool(20_000, 30_000, amm_core::FEE_TIER_BPS_30).expect("pool quote"); + let create_plan = plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: pool_definition.definition_token_a_id, + token_b_definition_id: pool_definition.definition_token_b_id, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + token_a_amount: create_quote.pool.reserve_a, + token_b_amount: create_quote.pool.reserve_b, + fees: amm_core::FEE_TIER_BPS_30, + deadline: u64::MAX, + }) + .expect("create plan"); + let Instruction::NewDefinition { + token_a_amount, + token_b_amount, + fees, + .. + } = create_plan.instruction() + else { + panic!("create planner emitted wrong instruction"); + }; + assert_eq!(*token_a_amount, create_quote.pool.reserve_a); + assert_eq!(*token_b_amount, create_quote.pool.reserve_b); + assert_eq!(*fees, amm_core::FEE_TIER_BPS_30); + + let add_preview = program_quote::preview_add_liquidity( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + 200, + 300, + ) + .expect("add preview"); + let add_quote = program_quote::add_liquidity( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + add_preview.actual_amount_a, + add_preview.actual_amount_b, + add_preview.liquidity_to_mint, + ) + .expect("exact add quote"); + let add_plan = plan_add_liquidity(AddLiquidityPlanInput { + context: &context, + pool, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + min_amount_liquidity: add_quote.liquidity_to_mint, + max_amount_to_add_token_a: add_quote.actual_amount_a, + max_amount_to_add_token_b: add_quote.actual_amount_b, + deadline: u64::MAX, + }); + let Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + .. + } = add_plan.instruction() + else { + panic!("add planner emitted wrong instruction"); + }; + assert_eq!(*min_amount_liquidity, add_quote.liquidity_to_mint); + assert_eq!(*max_amount_to_add_token_a, add_quote.actual_amount_a); + assert_eq!(*max_amount_to_add_token_b, add_quote.actual_amount_b); + + let remove_preview = program_quote::preview_remove_liquidity(&pool_definition, 500, 100) + .expect("remove preview"); + let remove_quote = program_quote::remove_liquidity( + &pool_definition, + 500, + remove_preview.liquidity_to_burn, + remove_preview.withdraw_amount_a, + remove_preview.withdraw_amount_b, + ) + .expect("exact remove quote"); + let remove_plan = plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &context, + pool, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + remove_liquidity_amount: remove_quote.liquidity_to_burn, + min_amount_to_remove_token_a: remove_quote.withdraw_amount_a, + min_amount_to_remove_token_b: remove_quote.withdraw_amount_b, + deadline: u64::MAX, + }); + let Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + .. + } = remove_plan.instruction() + else { + panic!("remove planner emitted wrong instruction"); + }; + assert_eq!(*remove_liquidity_amount, remove_quote.liquidity_to_burn); + assert_eq!( + *min_amount_to_remove_token_a, + remove_quote.withdraw_amount_a + ); + assert_eq!( + *min_amount_to_remove_token_b, + remove_quote.withdraw_amount_b + ); + + let swap_input_preview = program_quote::preview_swap_exact_input( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + program_quote::SwapDirection::AToB, + 100, + ) + .expect("exact-input preview"); + let swap_input_quote = program_quote::swap_exact_input( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + program_quote::SwapDirection::AToB, + swap_input_preview.amount_in, + swap_input_preview.amount_out, + ) + .expect("exact-input quote"); + let swap_input_plan = plan_swap_exact_input(SwapExactInputPlanInput { + context: &context, + pool, + user_input_holding: account(31), + user_output_holding: account(32), + swap_amount_in: swap_input_quote.amount_in, + min_amount_out: swap_input_quote.amount_out, + deadline: u64::MAX, + }); + let Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + .. + } = swap_input_plan.instruction() + else { + panic!("exact-input planner emitted wrong instruction"); + }; + assert_eq!(*swap_amount_in, swap_input_quote.amount_in); + assert_eq!(*min_amount_out, swap_input_quote.amount_out); + + let swap_output_preview = program_quote::preview_swap_exact_output( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + program_quote::SwapDirection::BToA, + 100, + ) + .expect("exact-output preview"); + let swap_output_quote = program_quote::swap_exact_output( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + program_quote::SwapDirection::BToA, + swap_output_preview.amount_out, + swap_output_preview.amount_in, + ) + .expect("exact-output quote"); + let swap_output_plan = plan_swap_exact_output(SwapExactOutputPlanInput { + context: &context, + pool, + user_input_holding: account(32), + user_output_holding: account(31), + exact_amount_out: swap_output_quote.amount_out, + max_amount_in: swap_output_quote.amount_in, + deadline: u64::MAX, + }); + let Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + .. + } = swap_output_plan.instruction() + else { + panic!("exact-output planner emitted wrong instruction"); + }; + assert_eq!(*exact_amount_out, swap_output_quote.amount_out); + assert_eq!(*max_amount_in, swap_output_quote.amount_in); +} + +#[test] +fn planners_derive_protocol_accounts_from_canonical_helpers() { + let context = context(); + let (pool_id, pool) = pool_fixture(&context); + + let observations = plan_create_price_observations(CreatePriceObservationsPlanInput { + context: &context, + pool_id, + window_duration: WINDOW_DURATION, + }); + assert_eq!( + account_for_role(&observations, AccountRole::CurrentTickAccount), + compute_current_tick_account_pda(context.twap_oracle_program_id(), pool_id) + ); + assert_eq!( + account_for_role(&observations, AccountRole::PriceObservations), + compute_price_observations_pda(context.twap_oracle_program_id(), pool_id, WINDOW_DURATION) + ); + assert_eq!( + account_for_role(&observations, AccountRole::Clock), + CLOCK_01_PROGRAM_ACCOUNT_ID + ); + + let oracle = plan_create_oracle_price_account(CreateOraclePriceAccountPlanInput { + context: &context, + pool_id, + window_duration: WINDOW_DURATION, + }); + assert_eq!( + account_for_role(&oracle, AccountRole::OraclePriceAccount), + compute_oracle_price_account_pda( + context.twap_oracle_program_id(), + pool_id, + WINDOW_DURATION + ) + ); + + let create = plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: pool.definition_token_a_id, + token_b_definition_id: pool.definition_token_b_id, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + token_a_amount: 20_000, + token_b_amount: 30_000, + fees: amm_core::FEE_TIER_BPS_30, + deadline: u64::MAX, + }) + .expect("distinct definitions"); + assert_eq!(account_for_role(&create, AccountRole::Pool), pool_id); + assert_eq!( + account_for_role(&create, AccountRole::VaultA), + pool.vault_a_id + ); + assert_eq!( + account_for_role(&create, AccountRole::VaultB), + pool.vault_b_id + ); + assert_eq!( + account_for_role(&create, AccountRole::PoolDefinitionLp), + pool.liquidity_pool_id + ); + assert_eq!( + account_for_role(&create, AccountRole::LpLockHolding), + amm_core::compute_lp_lock_holding_pda(context.amm_program_id, pool_id) + ); +} + +#[test] +fn equal_token_pool_returns_error_without_panicking() { + let context = context(); + let result = plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: account(3), + token_b_definition_id: account(3), + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + token_a_amount: 20_000, + token_b_amount: 30_000, + fees: amm_core::FEE_TIER_BPS_30, + deadline: u64::MAX, + }); + + assert!(matches!( + result, + Err(ClientError::IdenticalTokenDefinitions) + )); +} + +#[test] +fn pool_context_rejects_noncanonical_identity_fields() { + let context = context(); + let (pool_id, mut pool) = pool_fixture(&context); + pool.vault_a_id = account(200); + + let result = PoolContext::new(&context, pool_id, &pool); + assert!(matches!( + result, + Err(ClientError::AccountIdMismatch { + account: "vault_a", + .. + }) + )); +} + +fn account_for_role(plan: &TransactionPlan, role: AccountRole) -> AccountId { + plan.accounts() + .iter() + .find(|account| account.role() == role) + .map(|account| account.id()) + .expect("plan must contain requested role") +} + +fn string_field<'a>(value: &'a Value, field: &str) -> &'a str { + value + .get(field) + .and_then(Value::as_str) + .expect("IDL string field") +} + +fn bool_field(value: &Value, field: &str) -> bool { + value + .get(field) + .and_then(Value::as_bool) + .expect("IDL boolean field") +} + +const fn variant_index(instruction: &Instruction) -> u32 { + match instruction { + Instruction::Initialize { .. } => 0, + Instruction::UpdateConfig { .. } => 1, + Instruction::CreatePriceObservations { .. } => 2, + Instruction::CreateOraclePriceAccount { .. } => 3, + Instruction::NewDefinition { .. } => 4, + Instruction::AddLiquidity { .. } => 5, + Instruction::RemoveLiquidity { .. } => 6, + Instruction::SwapExactInput { .. } => 7, + Instruction::SwapExactOutput { .. } => 8, + Instruction::SyncReserves => 9, + } +} diff --git a/programs/amm/client/tests/quote_contract.rs b/programs/amm/client/tests/quote_contract.rs new file mode 100644 index 00000000..549767ce --- /dev/null +++ b/programs/amm/client/tests/quote_contract.rs @@ -0,0 +1,647 @@ +use amm_client::{ + plan_add_liquidity, plan_create_pool, plan_remove_liquidity, plan_swap_exact_input, + plan_swap_exact_output, prepare_add_liquidity, prepare_create_pool, prepare_remove_liquidity, + prepare_swap_exact_input, prepare_swap_exact_output, + quote::{ + self, AccountSnapshot, ValidatedFungibleDefinition, ValidatedFungibleHolding, + ValidatedPoolSnapshot, + }, + AddLiquidityPlanInput, AmmContext, ClientError, CreatePoolPlanInput, PoolContext, + RemoveLiquidityPlanInput, SlippageTolerance, SwapExactInputPlanInput, SwapExactOutputPlanInput, +}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, + AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, +}; +use amm_program::quote as program_quote; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::OBSERVATIONS_CAPACITY; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; +const LP_SUPPLY: u128 = 2_000; +const RESERVE_A: u128 = 1_000; +const RESERVE_B: u128 = 500; +const VAULT_A_BALANCE: u128 = 1_100; +const VAULT_B_BALANCE: u128 = 550; + +fn token_a_id() -> AccountId { + AccountId::new([1; 32]) +} + +fn token_b_id() -> AccountId { + AccountId::new([2; 32]) +} + +fn pool_id() -> AccountId { + compute_pool_pda(AMM_PROGRAM_ID, token_a_id(), token_b_id()) +} + +fn vault_a_id() -> AccountId { + compute_vault_pda(AMM_PROGRAM_ID, pool_id(), token_a_id()) +} + +fn vault_b_id() -> AccountId { + compute_vault_pda(AMM_PROGRAM_ID, pool_id(), token_b_id()) +} + +fn liquidity_definition_id() -> AccountId { + compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id()) +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn fungible_definition( + account_id: AccountId, + total_supply: u128, + authority: Option, +) -> AccountSnapshot { + AccountSnapshot::new( + account_id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ), + ) +} + +fn fungible_holding( + account_id: AccountId, + definition_id: AccountId, + balance: u128, +) -> AccountSnapshot { + AccountSnapshot::new( + account_id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ), + ) +} + +struct Fixture { + context: AmmContext, + pool: AccountSnapshot, + token_a_definition: AccountSnapshot, + token_b_definition: AccountSnapshot, + vault_a: AccountSnapshot, + vault_b: AccountSnapshot, + liquidity_definition: AccountSnapshot, +} + +impl Fixture { + fn new() -> Self { + let config = AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }; + let config_account = AccountSnapshot::new( + compute_config_pda(AMM_PROGRAM_ID), + account(AMM_PROGRAM_ID, Data::from(&config)), + ); + let context = AmmContext::from_config_account(AMM_PROGRAM_ID, &config_account) + .expect("canonical config snapshot must validate"); + let pool_definition = PoolDefinition { + definition_token_a_id: token_a_id(), + definition_token_b_id: token_b_id(), + vault_a_id: vault_a_id(), + vault_b_id: vault_b_id(), + liquidity_pool_id: liquidity_definition_id(), + liquidity_pool_supply: LP_SUPPLY, + reserve_a: RESERVE_A, + reserve_b: RESERVE_B, + fees: FEE_TIER_BPS_30, + }; + + Self { + context, + pool: AccountSnapshot::new( + pool_id(), + account(AMM_PROGRAM_ID, Data::from(&pool_definition)), + ), + token_a_definition: fungible_definition(token_a_id(), 100_000, None), + token_b_definition: fungible_definition(token_b_id(), 100_000, None), + vault_a: fungible_holding(vault_a_id(), token_a_id(), VAULT_A_BALANCE), + vault_b: fungible_holding(vault_b_id(), token_b_id(), VAULT_B_BALANCE), + liquidity_definition: fungible_definition( + liquidity_definition_id(), + LP_SUPPLY, + Some(liquidity_definition_id()), + ), + } + } + + fn validated_pool(&self) -> Result { + ValidatedPoolSnapshot::new( + &self.context, + &self.pool, + &self.token_a_definition, + &self.token_b_definition, + &self.vault_a, + &self.vault_b, + &self.liquidity_definition, + ) + } + + fn token_a(&self) -> ValidatedFungibleDefinition { + ValidatedFungibleDefinition::new(&self.context, &self.token_a_definition) + .expect("token A definition must validate") + } + + fn token_b(&self) -> ValidatedFungibleDefinition { + ValidatedFungibleDefinition::new(&self.context, &self.token_b_definition) + .expect("token B definition must validate") + } + + fn liquidity_token(&self) -> ValidatedFungibleDefinition { + ValidatedFungibleDefinition::new(&self.context, &self.liquidity_definition) + .expect("liquidity definition must validate") + } +} + +#[test] +fn validates_context_pool_vaults_and_fungible_definitions() { + let fixture = Fixture::new(); + let snapshot = fixture + .validated_pool() + .expect("canonical pool snapshot must validate"); + + assert_eq!(fixture.context.amm_program_id, AMM_PROGRAM_ID); + assert_eq!(fixture.context.token_program_id(), TOKEN_PROGRAM_ID); + assert_eq!(snapshot.pool_id(), pool_id()); + assert_eq!(snapshot.pool().reserve_a, RESERVE_A); + assert_eq!(snapshot.pool().reserve_b, RESERVE_B); + assert_eq!(snapshot.vault_a().balance(), VAULT_A_BALANCE); + assert_eq!(snapshot.vault_b().balance(), VAULT_B_BALANCE); + assert_eq!(snapshot.token_a_definition().account_id(), token_a_id()); + assert_eq!(snapshot.token_b_definition().account_id(), token_b_id()); + assert_eq!( + snapshot.liquidity_definition().total_supply(), + snapshot.pool().liquidity_pool_supply + ); +} + +#[test] +fn rejects_unrelated_vault_even_when_its_holding_data_matches() { + let fixture = Fixture::new(); + let unrelated_vault = + AccountSnapshot::new(AccountId::new([99; 32]), fixture.vault_a.account().clone()); + let result = ValidatedPoolSnapshot::new( + &fixture.context, + &fixture.pool, + &fixture.token_a_definition, + &fixture.token_b_definition, + &unrelated_vault, + &fixture.vault_b, + &fixture.liquidity_definition, + ); + let error = result.err().expect("unrelated vault must be rejected"); + + assert_eq!(error.code(), "account_id_mismatch"); + assert!(matches!( + error, + ClientError::AccountIdMismatch { + account: "vault A", + expected, + actual, + } if expected == vault_a_id() && actual == AccountId::new([99; 32]) + )); +} + +#[test] +fn rejects_inconsistent_liquidity_definition_state() { + let fixture = Fixture::new(); + let wrong_supply = fungible_definition( + liquidity_definition_id(), + 1_999, + Some(liquidity_definition_id()), + ); + let result = ValidatedPoolSnapshot::new( + &fixture.context, + &fixture.pool, + &fixture.token_a_definition, + &fixture.token_b_definition, + &fixture.vault_a, + &fixture.vault_b, + &wrong_supply, + ); + let error = result + .err() + .expect("LP definition supply mismatch must be rejected"); + + assert_eq!(error.code(), "invalid_account_data"); +} + +#[test] +fn client_quotes_match_program_quotes_for_every_economic_operation() { + let fixture = Fixture::new(); + let snapshot = fixture + .validated_pool() + .expect("canonical pool snapshot must validate"); + let token_a = fixture.token_a(); + let token_b = fixture.token_b(); + let liquidity_token = fixture.liquidity_token(); + let user_a = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([20; 32]), token_a_id(), 10_000), + &token_a, + ) + .expect("user token-A holding must validate"); + let user_b = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([21; 32]), token_b_id(), 10_000), + &token_b, + ) + .expect("user token-B holding must validate"); + let user_liquidity = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([22; 32]), liquidity_definition_id(), 1_000), + &liquidity_token, + ) + .expect("user LP holding must validate"); + + assert_eq!( + quote::create_pool( + &fixture.context, + &token_a, + &token_b, + 4_000, + 9_000, + FEE_TIER_BPS_30 + ), + program_quote::create_pool(4_000, 9_000, FEE_TIER_BPS_30).map_err(ClientError::from) + ); + assert_eq!( + quote::preview_add_liquidity(&snapshot, 400, 100), + program_quote::preview_add_liquidity( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + 400, + 100, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::add_liquidity(&snapshot, 400, 100, 399), + program_quote::add_liquidity( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + 400, + 100, + 399, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::preview_remove_liquidity(&snapshot, &user_liquidity, 500), + program_quote::preview_remove_liquidity(snapshot.pool(), 1_000, 500) + .map_err(ClientError::from) + ); + assert_eq!( + quote::remove_liquidity(&snapshot, &user_liquidity, 500, 250, 125), + program_quote::remove_liquidity(snapshot.pool(), 1_000, 500, 250, 125) + .map_err(ClientError::from) + ); + assert_eq!( + quote::preview_swap_exact_input(&snapshot, &user_a, &user_b, 100), + program_quote::preview_swap_exact_input( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + program_quote::SwapDirection::AToB, + 100, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::swap_exact_input(&snapshot, &user_b, &user_a, 100, 165), + program_quote::swap_exact_input( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + program_quote::SwapDirection::BToA, + 100, + 165, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::preview_swap_exact_output(&snapshot, &user_a, &user_b, 45), + program_quote::preview_swap_exact_output( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + program_quote::SwapDirection::AToB, + 45, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::swap_exact_output(&snapshot, &user_a, &user_b, 45, 100), + program_quote::swap_exact_output( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + program_quote::SwapDirection::AToB, + 45, + 100, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::sync_reserves(&snapshot), + program_quote::sync_reserves(snapshot.pool(), VAULT_A_BALANCE, VAULT_B_BALANCE) + .map_err(ClientError::from) + ); + let window_duration = u64::from(OBSERVATIONS_CAPACITY); + assert_eq!( + quote::create_oracle_price_account(&snapshot, window_duration), + program_quote::create_oracle_price_account(snapshot.pool(), window_duration) + .map_err(ClientError::from) + ); + assert_eq!( + quote::pair_order(&snapshot, &token_b, &token_a), + Ok(program_quote::PairOrder::Reversed) + ); +} + +#[test] +fn swap_rejects_unrelated_output_and_insufficient_input_balance() { + let fixture = Fixture::new(); + let snapshot = fixture + .validated_pool() + .expect("canonical pool snapshot must validate"); + let token_a = fixture.token_a(); + let token_b = fixture.token_b(); + let token_c_account = fungible_definition(AccountId::new([3; 32]), 100_000, None); + let token_c = ValidatedFungibleDefinition::new(&fixture.context, &token_c_account) + .expect("third fungible definition must validate"); + let user_a = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([20; 32]), token_a_id(), 99), + &token_a, + ) + .expect("user token-A holding must validate"); + let user_b = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([21; 32]), token_b_id(), 0), + &token_b, + ) + .expect("user token-B holding must validate"); + let user_c = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([23; 32]), token_c.account_id(), 0), + &token_c, + ) + .expect("user token-C holding must validate"); + + let unrelated_output = quote::swap_exact_input(&snapshot, &user_a, &user_c, 99, 1) + .expect_err("unrelated output holding must be rejected"); + assert_eq!(unrelated_output.code(), "token_definition_mismatch"); + + let insufficient = quote::swap_exact_input(&snapshot, &user_a, &user_b, 100, 1) + .expect_err("input above the holding balance must be rejected"); + assert_eq!(insufficient.code(), "insufficient_balance"); + assert!(matches!( + insufficient, + ClientError::InsufficientBalance { + account: "user input holding", + available: 99, + required: 100, + } + )); +} + +#[test] +fn raw_amounts_above_javascript_integer_range_remain_exact() { + const ABOVE_TWO_POW_53: u128 = 9_007_199_254_740_993; + const USER_LIQUIDITY: u128 = 9_007_199_254_739_993; + + let fixture = Fixture::new(); + let token_a = fixture.token_a(); + let token_b = fixture.token_b(); + let quote = quote::create_pool( + &fixture.context, + &token_a, + &token_b, + ABOVE_TWO_POW_53, + ABOVE_TWO_POW_53, + FEE_TIER_BPS_30, + ) + .expect("large exact integer amounts must quote"); + let holding = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([20; 32]), token_a_id(), ABOVE_TWO_POW_53), + &token_a, + ) + .expect("large exact integer holding must validate"); + + assert_eq!(quote.pool.reserve_a, ABOVE_TWO_POW_53); + assert_eq!(quote.pool.reserve_b, ABOVE_TWO_POW_53); + assert_eq!(quote.pool.liquidity_pool_supply, ABOVE_TWO_POW_53); + assert_eq!(quote.locked_liquidity, MINIMUM_LIQUIDITY); + assert_eq!(quote.user_liquidity, USER_LIQUIDITY); + assert_eq!(holding.balance(), ABOVE_TWO_POW_53); +} + +#[test] +fn prepared_instruction_args_feed_canonical_planners_without_ui_math() { + let fixture = Fixture::new(); + let snapshot = fixture + .validated_pool() + .expect("canonical pool snapshot must validate"); + let pool = PoolContext::new(&fixture.context, snapshot.pool_id(), snapshot.pool()) + .expect("validated pool has canonical identity"); + let token_a = fixture.token_a(); + let token_b = fixture.token_b(); + let liquidity_token = fixture.liquidity_token(); + let user_holding_a_id = AccountId::new([20; 32]); + let user_holding_b_id = AccountId::new([21; 32]); + let user_holding_lp_id = AccountId::new([22; 32]); + let user_a = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(user_holding_a_id, token_a_id(), 10_000), + &token_a, + ) + .expect("user token-A holding must validate"); + let user_b = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(user_holding_b_id, token_b_id(), 10_000), + &token_b, + ) + .expect("user token-B holding must validate"); + let user_liquidity = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(user_holding_lp_id, liquidity_definition_id(), 1_000), + &liquidity_token, + ) + .expect("user LP holding must validate"); + let tolerance = SlippageTolerance::new(100).expect("one percent is valid"); + let deadline = u64::MAX; + + let prepared_create = prepare_create_pool( + &fixture.context, + &token_a, + &token_b, + 4_000, + 9_000, + FEE_TIER_BPS_30, + ) + .expect("pool creation must prepare"); + let create_plan = plan_create_pool(CreatePoolPlanInput { + context: &fixture.context, + token_a_definition_id: token_a.account_id(), + token_b_definition_id: token_b.account_id(), + user_holding_a: user_holding_a_id, + user_holding_b: user_holding_b_id, + user_holding_lp: user_holding_lp_id, + token_a_amount: prepared_create.token_a_amount, + token_b_amount: prepared_create.token_b_amount, + fees: prepared_create.fees, + deadline, + }) + .expect("prepared create args must plan"); + assert!(matches!( + create_plan.instruction(), + Instruction::NewDefinition { + token_a_amount, + token_b_amount, + fees, + deadline: planned_deadline, + } if *token_a_amount == prepared_create.token_a_amount + && *token_b_amount == prepared_create.token_b_amount + && *fees == prepared_create.fees + && *planned_deadline == deadline + )); + + let prepared_add = + prepare_add_liquidity(&snapshot, 400, 100, tolerance).expect("add liquidity must prepare"); + assert_eq!(prepared_add.max_amount_to_add_token_a, 200); + assert_eq!(prepared_add.max_amount_to_add_token_b, 100); + assert_eq!( + prepared_add.max_amount_to_add_token_a, + prepared_add.quote.actual_amount_a + ); + assert_eq!( + prepared_add.max_amount_to_add_token_b, + prepared_add.quote.actual_amount_b + ); + let add_plan = plan_add_liquidity(AddLiquidityPlanInput { + context: &fixture.context, + pool, + user_holding_a: user_holding_a_id, + user_holding_b: user_holding_b_id, + user_holding_lp: user_holding_lp_id, + min_amount_liquidity: prepared_add.min_amount_liquidity, + max_amount_to_add_token_a: prepared_add.max_amount_to_add_token_a, + max_amount_to_add_token_b: prepared_add.max_amount_to_add_token_b, + deadline, + }); + assert!(matches!( + add_plan.instruction(), + Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + deadline: planned_deadline, + } if *min_amount_liquidity == prepared_add.min_amount_liquidity + && *max_amount_to_add_token_a == prepared_add.max_amount_to_add_token_a + && *max_amount_to_add_token_b == prepared_add.max_amount_to_add_token_b + && *planned_deadline == deadline + )); + + let prepared_remove = prepare_remove_liquidity(&snapshot, &user_liquidity, 500, tolerance) + .expect("remove liquidity must prepare"); + let remove_plan = plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &fixture.context, + pool, + user_holding_a: user_holding_a_id, + user_holding_b: user_holding_b_id, + user_holding_lp: user_holding_lp_id, + remove_liquidity_amount: prepared_remove.remove_liquidity_amount, + min_amount_to_remove_token_a: prepared_remove.min_amount_to_remove_token_a, + min_amount_to_remove_token_b: prepared_remove.min_amount_to_remove_token_b, + deadline, + }); + assert!(matches!( + remove_plan.instruction(), + Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + deadline: planned_deadline, + } if *remove_liquidity_amount == prepared_remove.remove_liquidity_amount + && *min_amount_to_remove_token_a == prepared_remove.min_amount_to_remove_token_a + && *min_amount_to_remove_token_b == prepared_remove.min_amount_to_remove_token_b + && *planned_deadline == deadline + )); + + let prepared_exact_input = + prepare_swap_exact_input(&snapshot, &user_a, &user_b, 100, tolerance) + .expect("exact-input swap must prepare"); + let exact_input_plan = plan_swap_exact_input(SwapExactInputPlanInput { + context: &fixture.context, + pool, + user_input_holding: user_holding_a_id, + user_output_holding: user_holding_b_id, + swap_amount_in: prepared_exact_input.swap_amount_in, + min_amount_out: prepared_exact_input.min_amount_out, + deadline, + }); + assert!(matches!( + exact_input_plan.instruction(), + Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + deadline: planned_deadline, + } if *swap_amount_in == prepared_exact_input.swap_amount_in + && *min_amount_out == prepared_exact_input.min_amount_out + && *planned_deadline == deadline + )); + + let prepared_exact_output = + prepare_swap_exact_output(&snapshot, &user_a, &user_b, 45, tolerance) + .expect("exact-output swap must prepare"); + let exact_output_plan = plan_swap_exact_output(SwapExactOutputPlanInput { + context: &fixture.context, + pool, + user_input_holding: user_holding_a_id, + user_output_holding: user_holding_b_id, + exact_amount_out: prepared_exact_output.exact_amount_out, + max_amount_in: prepared_exact_output.max_amount_in, + deadline, + }); + assert!(matches!( + exact_output_plan.instruction(), + Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + deadline: planned_deadline, + } if *exact_amount_out == prepared_exact_output.exact_amount_out + && *max_amount_in == prepared_exact_output.max_amount_in + && *planned_deadline == deadline + )); +} diff --git a/programs/amm/client/tests/slippage_contract.rs b/programs/amm/client/tests/slippage_contract.rs new file mode 100644 index 00000000..df3afdb0 --- /dev/null +++ b/programs/amm/client/tests/slippage_contract.rs @@ -0,0 +1,91 @@ +use amm_client::{ + maximum_guard_amount, minimum_guard_amount, ClientError, SlippageTolerance, + SLIPPAGE_BPS_DENOMINATOR, +}; + +const ABOVE_TWO_POW_53: u128 = 9_007_199_254_740_993; + +#[test] +fn tolerance_accepts_closed_basis_point_range() { + assert_eq!(SlippageTolerance::new(0).expect("zero is valid").bps(), 0); + assert_eq!( + SlippageTolerance::new(SLIPPAGE_BPS_DENOMINATOR) + .expect("one hundred percent is valid") + .bps(), + SLIPPAGE_BPS_DENOMINATOR + ); + + let error = SlippageTolerance::new(SLIPPAGE_BPS_DENOMINATOR + 1) + .expect_err("more than one hundred percent must be rejected"); + assert_eq!(error.code(), "slippage_tolerance_out_of_range"); + assert!(matches!( + error, + ClientError::SlippageToleranceOutOfRange { + bps, + maximum_bps, + } if bps == 10_001 && maximum_bps == 10_000 + )); +} + +#[test] +fn minimum_guards_round_down_and_stay_executable() { + let one_percent = SlippageTolerance::new(100).expect("valid tolerance"); + assert_eq!(minimum_guard_amount(100, one_percent), Ok(99)); + assert_eq!(minimum_guard_amount(101, one_percent), Ok(99)); + assert_eq!(minimum_guard_amount(1, one_percent), Ok(1)); + assert_eq!( + minimum_guard_amount(1, SlippageTolerance::new(10_000).expect("valid tolerance")), + Ok(1) + ); + assert_eq!(minimum_guard_amount(0, one_percent), Ok(0)); +} + +#[test] +fn maximum_guards_round_up() { + let one_percent = SlippageTolerance::new(100).expect("valid tolerance"); + assert_eq!(maximum_guard_amount(100, one_percent), Ok(101)); + assert_eq!(maximum_guard_amount(101, one_percent), Ok(103)); + assert_eq!(maximum_guard_amount(0, one_percent), Ok(0)); +} + +#[test] +fn maximum_guard_reports_u128_overflow() { + let error = maximum_guard_amount( + u128::MAX, + SlippageTolerance::new(1).expect("valid tolerance"), + ) + .expect_err("expanded maximum must not saturate"); + + assert_eq!(error.code(), "slippage_bound_overflow"); + assert!(matches!( + error, + ClientError::SlippageBoundOverflow { + quoted_amount: u128::MAX, + slippage_bps: 1, + } + )); +} + +#[test] +fn guards_preserve_amounts_above_javascript_integer_range() { + let tolerance = SlippageTolerance::new(1).expect("valid tolerance"); + + assert_eq!( + minimum_guard_amount(ABOVE_TWO_POW_53, tolerance), + amm_core::checked_mul_div_floor(ABOVE_TWO_POW_53, 9_999, 10_000).ok_or( + ClientError::SlippageBoundOverflow { + quoted_amount: ABOVE_TWO_POW_53, + slippage_bps: 1, + } + ) + ); + assert_eq!( + maximum_guard_amount(ABOVE_TWO_POW_53, tolerance), + amm_core::checked_mul_div_ceil(ABOVE_TWO_POW_53, 10_001, 10_000).ok_or( + ClientError::SlippageBoundOverflow { + quoted_amount: ABOVE_TWO_POW_53, + slippage_bps: 1, + } + ) + ); +} diff --git a/programs/amm/client/tests/wire_prepare_contract.rs b/programs/amm/client/tests/wire_prepare_contract.rs new file mode 100644 index 00000000..ffd7a659 --- /dev/null +++ b/programs/amm/client/tests/wire_prepare_contract.rs @@ -0,0 +1,274 @@ +use amm_client::{maximum_guard_amount, minimum_guard_amount, wire::quote_json, SlippageTolerance}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, + AmmConfig, PoolDefinition, FEE_TIER_BPS_30, +}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn snapshot(id: AccountId, account: &Account) -> Value { + json!({ + "id": id.to_string(), + "programOwner": account.program_owner, + "balance": account.balance.to_string(), + "nonce": account.nonce.0.to_string(), + "data": account + .data + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + }) +} + +fn definition(total_supply: u128, authority: Option) -> Account { + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ) +} + +fn holding(definition_id: AccountId, balance: u128) -> Account { + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ) +} + +struct WireFixture { + token_a_id: AccountId, + token_b_id: AccountId, + config: Value, + state: Value, + user_a: Value, + user_b: Value, + user_lp: Value, +} + +impl WireFixture { + fn new() -> Self { + let token_a_id = AccountId::new([1; 32]); + let token_b_id = AccountId::new([2; 32]); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, token_a_id, token_b_id); + let vault_a_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, token_a_id); + let vault_b_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, token_b_id); + let liquidity_id = compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id); + let config = AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }; + let config = snapshot( + compute_config_pda(AMM_PROGRAM_ID), + &account(AMM_PROGRAM_ID, Data::from(&config)), + ); + let pool = PoolDefinition { + definition_token_a_id: token_a_id, + definition_token_b_id: token_b_id, + vault_a_id, + vault_b_id, + liquidity_pool_id: liquidity_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + let state = json!({ + "ammProgramId": AMM_PROGRAM_ID, + "config": config, + "snapshot": { + "pool": snapshot(pool_id, &account(AMM_PROGRAM_ID, Data::from(&pool))), + "tokenADefinition": snapshot(token_a_id, &definition(100_000, None)), + "tokenBDefinition": snapshot(token_b_id, &definition(100_000, None)), + "vaultA": snapshot(vault_a_id, &holding(token_a_id, 1_100)), + "vaultB": snapshot(vault_b_id, &holding(token_b_id, 550)), + "liquidityDefinition": snapshot( + liquidity_id, + &definition(2_000, Some(liquidity_id)), + ), + }, + }); + + Self { + token_a_id, + token_b_id, + config, + state, + user_a: snapshot(AccountId::new([20; 32]), &holding(token_a_id, 10_000)), + user_b: snapshot(AccountId::new([21; 32]), &holding(token_b_id, 10_000)), + user_lp: snapshot(AccountId::new([22; 32]), &holding(liquidity_id, 1_000)), + } + } + + fn request(&self, operation: &str) -> Value { + let mut request = self.state.clone(); + insert( + &mut request, + "operation", + Value::String(String::from(operation)), + ); + request + } +} + +fn insert(object: &mut Value, field: &str, value: Value) { + drop( + object + .as_object_mut() + .expect("fixture request must be an object") + .insert(String::from(field), value), + ); +} + +fn decimal(value: &Value) -> u128 { + value + .as_str() + .expect("chain amounts must be JSON strings") + .parse() + .expect("chain amounts must be decimal u128") +} + +#[test] +fn prepare_wire_operations_return_lossless_instruction_args() { + let fixture = WireFixture::new(); + let tolerance = SlippageTolerance::new(100).expect("one percent is valid"); + let large = 9_007_199_254_740_993_u128; + + let create = quote_json(json!({ + "operation": "prepare_create_pool", + "ammProgramId": AMM_PROGRAM_ID, + "config": fixture.config.clone(), + "tokenADefinition": snapshot(fixture.token_a_id, &definition(100_000, None)), + "tokenBDefinition": snapshot(fixture.token_b_id, &definition(100_000, None)), + "tokenAAmount": large.to_string(), + "tokenBAmount": large.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect("create pool must prepare"); + assert_eq!(create["instructionArgs"]["tokenAAmount"], large.to_string()); + assert_eq!(create["instructionArgs"]["tokenBAmount"], large.to_string()); + assert_eq!(create["instructionArgs"]["fees"], "30"); + + let mut add_request = fixture.request("prepare_add_liquidity"); + insert(&mut add_request, "maxAmountA", json!("400")); + insert(&mut add_request, "maxAmountB", json!("100")); + insert(&mut add_request, "slippageBps", json!("100")); + let add = quote_json(add_request).expect("add liquidity must prepare"); + assert_eq!( + decimal(&add["instructionArgs"]["minAmountLiquidity"]), + minimum_guard_amount(decimal(&add["quote"]["liquidityToMint"]), tolerance) + .expect("minimum LP guard must fit") + ); + assert_eq!(add["instructionArgs"]["maxAmountToAddTokenA"], "200"); + assert_eq!(add["instructionArgs"]["maxAmountToAddTokenB"], "100"); + + let mut remove_request = fixture.request("prepare_remove_liquidity"); + insert( + &mut remove_request, + "userLiquidityHolding", + fixture.user_lp.clone(), + ); + insert(&mut remove_request, "removeLiquidityAmount", json!("500")); + insert(&mut remove_request, "slippageBps", json!("100")); + let remove = quote_json(remove_request).expect("remove liquidity must prepare"); + assert_eq!(remove["instructionArgs"]["removeLiquidityAmount"], "500"); + assert_eq!( + decimal(&remove["instructionArgs"]["minAmountToRemoveTokenA"]), + minimum_guard_amount(decimal(&remove["quote"]["withdrawAmountA"]), tolerance) + .expect("minimum A guard must fit") + ); + assert_eq!( + decimal(&remove["instructionArgs"]["minAmountToRemoveTokenB"]), + minimum_guard_amount(decimal(&remove["quote"]["withdrawAmountB"]), tolerance) + .expect("minimum B guard must fit") + ); + + let mut exact_input_request = fixture.request("prepare_swap_exact_input"); + insert( + &mut exact_input_request, + "userInputHolding", + fixture.user_a.clone(), + ); + insert( + &mut exact_input_request, + "userOutputHolding", + fixture.user_b.clone(), + ); + insert( + &mut exact_input_request, + "inputTokenDefinitionId", + json!(fixture.token_a_id.to_string()), + ); + insert(&mut exact_input_request, "amountIn", json!("100")); + insert(&mut exact_input_request, "slippageBps", json!("100")); + let exact_input = quote_json(exact_input_request).expect("exact-input swap must prepare"); + assert_eq!(exact_input["instructionArgs"]["swapAmountIn"], "100"); + assert_eq!( + decimal(&exact_input["instructionArgs"]["minAmountOut"]), + minimum_guard_amount(decimal(&exact_input["quote"]["amountOut"]), tolerance) + .expect("minimum output guard must fit") + ); + + let mut exact_output_request = fixture.request("prepare_swap_exact_output"); + insert( + &mut exact_output_request, + "userInputHolding", + fixture.user_a, + ); + insert( + &mut exact_output_request, + "userOutputHolding", + fixture.user_b, + ); + insert( + &mut exact_output_request, + "inputTokenDefinitionId", + json!(fixture.token_a_id.to_string()), + ); + insert(&mut exact_output_request, "exactAmountOut", json!("45")); + insert(&mut exact_output_request, "slippageBps", json!("100")); + let exact_output = quote_json(exact_output_request).expect("exact-output swap must prepare"); + assert_eq!(exact_output["instructionArgs"]["exactAmountOut"], "45"); + assert_eq!( + decimal(&exact_output["instructionArgs"]["maxAmountIn"]), + maximum_guard_amount(decimal(&exact_output["quote"]["amountIn"]), tolerance) + .expect("maximum input guard must fit") + ); +} + +#[test] +fn prepare_wire_rejects_out_of_range_slippage() { + let fixture = WireFixture::new(); + let mut request = fixture.request("prepare_add_liquidity"); + insert(&mut request, "maxAmountA", json!("400")); + insert(&mut request, "maxAmountB", json!("100")); + insert(&mut request, "slippageBps", json!("10001")); + + let error = quote_json(request).expect_err("invalid slippage must be rejected"); + assert_eq!(error.code(), "slippage_tolerance_out_of_range"); +} diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index de759351..0cdd9444 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -257,7 +257,7 @@ 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] = [ +pub const SUPPORTED_FEE_TIERS: &[u128] = &[ FEE_TIER_BPS_1, FEE_TIER_BPS_5, FEE_TIER_BPS_30, diff --git a/programs/amm/src/quote.rs b/programs/amm/src/quote.rs index 917beae5..6e29a94b 100644 --- a/programs/amm/src/quote.rs +++ b/programs/amm/src/quote.rs @@ -14,25 +14,150 @@ use amm_core::{ use nssa_core::account::AccountId; use twap_oracle_core::OBSERVATIONS_CAPACITY; +/// Stable categories for quote failures. +/// +/// Consumers matching this enum must retain a fallback because new categories may be added as the +/// quote surface grows. [`QuoteErrorCode::as_str`] provides the stable API/FFI representation. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum QuoteErrorCode { + /// A checked amount calculation exceeded its representable range. + ArithmeticOverflow, + /// A proportional liquidity deposit rounded to zero. + DepositAmountZero, + /// A swap input rounded to zero after fees. + EffectiveSwapInputZero, + /// An exact-output request would consume the output reserve. + ExactOutputExceedsReserve, + /// An exact-output request was zero. + ExactOutputZero, + /// Initial liquidity did not exceed the permanent lock. + InitialLiquidityTooLow, + /// The selected swap input token is not in the pool. + InputTokenNotInPool, + /// The supplied LP balance is inconsistent with pool supply. + InvalidLiquidityAccount, + /// Pool LP supply is below the permanent lock. + LiquiditySupplyBelowMinimum, + /// At least one maximum liquidity deposit was zero. + MaximumDepositZero, + /// The minimum LP output guard was zero. + MinimumLiquidityZero, + /// At least one minimum withdrawal guard was zero. + MinimumWithdrawalZero, + /// Minted liquidity was below the caller's minimum. + MintedLiquidityBelowMinimum, + /// Minted liquidity rounded to zero. + MintedLiquidityZero, + /// The derived oracle price was the no-price sentinel. + OraclePriceZero, + /// The requested oracle window cannot hold the observation capacity. + OracleWindowTooShort, + /// A withdrawal was attempted from a pool containing only locked liquidity. + PoolContainsOnlyLockedLiquidity, + /// A withdrawal would consume permanently locked liquidity. + RemoveAmountExceedsUnlockedLiquidity, + /// A withdrawal exceeds the caller's LP balance. + RemoveAmountExceedsUserBalance, + /// The requested LP withdrawal was zero. + RemoveLiquidityAmountZero, + /// Exact-output input exceeded the caller's maximum. + RequiredInputExceedsMaximum, + /// Token-A reserve was zero where a spot price was required. + ReserveAZero, + /// At least one pool reserve was zero. + ReserveZero, + /// Exact-input output was below the caller's minimum. + SwapOutputBelowMinimum, + /// Swap output rounded to zero. + SwapOutputZero, + /// Initial token-A liquidity was zero. + TokenAAmountZero, + /// Initial token-B liquidity was zero. + TokenBAmountZero, + /// A token pair does not match the pool. + TokenPairNotInPool, + /// A pool fee is not one of the canonical tiers. + UnsupportedFeeTier, + /// Token-A vault balance is below the tracked reserve. + VaultABalanceBelowReserve, + /// Token-B vault balance is below the tracked reserve. + VaultBBalanceBelowReserve, + /// Token-A withdrawal was below the caller's minimum. + WithdrawalABelowMinimum, + /// Token-B withdrawal was below the caller's minimum. + WithdrawalBBelowMinimum, +} + +impl QuoteErrorCode { + /// Returns the stable machine-readable representation. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ArithmeticOverflow => "arithmetic_overflow", + Self::DepositAmountZero => "deposit_amount_zero", + Self::EffectiveSwapInputZero => "effective_swap_input_zero", + Self::ExactOutputExceedsReserve => "exact_output_exceeds_reserve", + Self::ExactOutputZero => "exact_output_zero", + Self::InitialLiquidityTooLow => "initial_liquidity_too_low", + Self::InputTokenNotInPool => "input_token_not_in_pool", + Self::InvalidLiquidityAccount => "invalid_liquidity_account", + Self::LiquiditySupplyBelowMinimum => "liquidity_supply_below_minimum", + Self::MaximumDepositZero => "maximum_deposit_zero", + Self::MinimumLiquidityZero => "minimum_liquidity_zero", + Self::MinimumWithdrawalZero => "minimum_withdrawal_zero", + Self::MintedLiquidityBelowMinimum => "minted_liquidity_below_minimum", + Self::MintedLiquidityZero => "minted_liquidity_zero", + Self::OraclePriceZero => "oracle_price_zero", + Self::OracleWindowTooShort => "oracle_window_too_short", + Self::PoolContainsOnlyLockedLiquidity => "pool_contains_only_locked_liquidity", + Self::RemoveAmountExceedsUnlockedLiquidity => { + "remove_amount_exceeds_unlocked_liquidity" + } + Self::RemoveAmountExceedsUserBalance => "remove_amount_exceeds_user_balance", + Self::RemoveLiquidityAmountZero => "remove_liquidity_amount_zero", + Self::RequiredInputExceedsMaximum => "required_input_exceeds_maximum", + Self::ReserveAZero => "reserve_a_zero", + Self::ReserveZero => "reserve_zero", + Self::SwapOutputBelowMinimum => "swap_output_below_minimum", + Self::SwapOutputZero => "swap_output_zero", + Self::TokenAAmountZero => "token_a_amount_zero", + Self::TokenBAmountZero => "token_b_amount_zero", + Self::TokenPairNotInPool => "token_pair_not_in_pool", + Self::UnsupportedFeeTier => "unsupported_fee_tier", + Self::VaultABalanceBelowReserve => "vault_a_balance_below_reserve", + Self::VaultBBalanceBelowReserve => "vault_b_balance_below_reserve", + Self::WithdrawalABelowMinimum => "withdrawal_a_below_minimum", + Self::WithdrawalBBelowMinimum => "withdrawal_b_below_minimum", + } + } +} + /// 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. +/// Consumers should branch on [`QuoteError::kind`] or [`QuoteError::code`] and treat +/// [`QuoteError::message`] as display or diagnostic text. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct QuoteError { - code: &'static str, + kind: QuoteErrorCode, message: &'static str, } impl QuoteError { - const fn new(code: &'static str, message: &'static str) -> Self { - Self { code, message } + const fn new(kind: QuoteErrorCode, message: &'static str) -> Self { + Self { kind, message } + } + + /// Returns the typed error category. + #[must_use] + pub const fn kind(&self) -> QuoteErrorCode { + self.kind } /// Returns the stable machine-readable error code. #[must_use] pub const fn code(&self) -> &'static str { - self.code + self.kind.as_str() } /// Returns the program-facing failure message. @@ -94,7 +219,7 @@ pub fn pair_order( Ok(PairOrder::Reversed) } else { Err(QuoteError::new( - "token_pair_not_in_pool", + QuoteErrorCode::TokenPairNotInPool, "Token pair does not match the pool", )) } @@ -120,13 +245,14 @@ pub fn swap_direction( Ok(SwapDirection::BToA) } else { Err(QuoteError::new( - "input_token_not_in_pool", + QuoteErrorCode::InputTokenNotInPool, "Input token is not part of the pool", )) } } /// Pool scalar values after a quoted operation. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PoolUpdate { /// Total LP supply after the operation. @@ -153,6 +279,7 @@ impl PoolUpdate { } /// Result of creating a pool's initial liquidity position. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CreatePoolQuote { /// Initial pool scalar values. @@ -171,13 +298,13 @@ pub fn create_pool( ) -> Result { if token_a_amount == 0 { return Err(QuoteError::new( - "token_a_amount_zero", + QuoteErrorCode::TokenAAmountZero, "token_a_amount must be nonzero", )); } if token_b_amount == 0 { return Err(QuoteError::new( - "token_b_amount_zero", + QuoteErrorCode::TokenBAmountZero, "token_b_amount must be nonzero", )); } @@ -186,7 +313,7 @@ pub fn create_pool( let initial_liquidity = isqrt_product(token_a_amount, token_b_amount); if initial_liquidity <= MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "initial_liquidity_too_low", + QuoteErrorCode::InitialLiquidityTooLow, "Initial liquidity must exceed minimum liquidity lock", )); } @@ -194,7 +321,7 @@ pub fn create_pool( .checked_sub(MINIMUM_LIQUIDITY) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "initial liquidity must exceed minimum liquidity after validation", ) })?; @@ -208,6 +335,7 @@ pub fn create_pool( } /// Result of adding liquidity to an initialized pool. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AddLiquidityQuote { /// Token-A amount transferred into the pool. @@ -253,13 +381,13 @@ pub fn add_liquidity( ensure_supported_fee_tier(pool.fees)?; if minimum_liquidity == 0 { return Err(QuoteError::new( - "minimum_liquidity_zero", + QuoteErrorCode::MinimumLiquidityZero, "min_amount_liquidity must be nonzero", )); } if max_amount_a == 0 || max_amount_b == 0 { return Err(QuoteError::new( - "maximum_deposit_zero", + QuoteErrorCode::MaximumDepositZero, "Both max-balances must be nonzero", )); } @@ -271,7 +399,10 @@ pub fn add_liquidity( "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")); + return Err(QuoteError::new( + QuoteErrorCode::ReserveZero, + "Reserves must be nonzero", + )); } let ideal_a = checked_floor( @@ -290,7 +421,7 @@ pub fn add_liquidity( 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", + QuoteErrorCode::DepositAmountZero, "A trade amount is 0", )); } @@ -310,13 +441,13 @@ pub fn add_liquidity( let liquidity_to_mint = liquidity_from_a.min(liquidity_from_b); if liquidity_to_mint == 0 { return Err(QuoteError::new( - "minted_liquidity_zero", + QuoteErrorCode::MintedLiquidityZero, "Payable LP must be nonzero", )); } if liquidity_to_mint < minimum_liquidity { return Err(QuoteError::new( - "minted_liquidity_below_minimum", + QuoteErrorCode::MintedLiquidityBelowMinimum, "Payable LP is less than provided minimum LP amount", )); } @@ -326,19 +457,19 @@ pub fn add_liquidity( .checked_add(liquidity_to_mint) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "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", + QuoteErrorCode::ArithmeticOverflow, "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", + QuoteErrorCode::ArithmeticOverflow, "reserve_b + actual_amount_b overflows u128", ) })?; @@ -352,6 +483,7 @@ pub fn add_liquidity( } /// Result of removing liquidity from a pool. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct RemoveLiquidityQuote { /// Token-A amount withdrawn from the pool. @@ -387,37 +519,37 @@ pub fn remove_liquidity( ensure_supported_fee_tier(pool.fees)?; if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "liquidity_supply_below_minimum", + QuoteErrorCode::LiquiditySupplyBelowMinimum, "Pool liquidity supply is below minimum liquidity", )); } if minimum_amount_a == 0 || minimum_amount_b == 0 { return Err(QuoteError::new( - "minimum_withdrawal_zero", + QuoteErrorCode::MinimumWithdrawalZero, "Minimum withdraw amount must be nonzero", )); } if user_liquidity_balance > pool.liquidity_pool_supply { return Err(QuoteError::new( - "invalid_liquidity_account", + QuoteErrorCode::InvalidLiquidityAccount, "Invalid liquidity account provided", )); } if pool.liquidity_pool_supply == MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "pool_contains_only_locked_liquidity", + QuoteErrorCode::PoolContainsOnlyLockedLiquidity, "Pool only contains locked liquidity", )); } if remove_liquidity_amount == 0 { return Err(QuoteError::new( - "remove_liquidity_amount_zero", + QuoteErrorCode::RemoveLiquidityAmountZero, "remove_liquidity_amount must be nonzero", )); } if remove_liquidity_amount > user_liquidity_balance { return Err(QuoteError::new( - "remove_amount_exceeds_user_balance", + QuoteErrorCode::RemoveAmountExceedsUserBalance, "Remove amount exceeds user LP balance", )); } @@ -426,13 +558,13 @@ pub fn remove_liquidity( .checked_sub(MINIMUM_LIQUIDITY) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "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", + QuoteErrorCode::RemoveAmountExceedsUnlockedLiquidity, "Cannot remove locked minimum liquidity", )); } @@ -451,13 +583,13 @@ pub fn remove_liquidity( )?; if withdraw_amount_a < minimum_amount_a { return Err(QuoteError::new( - "withdrawal_a_below_minimum", + QuoteErrorCode::WithdrawalABelowMinimum, "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", + QuoteErrorCode::WithdrawalBBelowMinimum, "Insufficient minimal withdraw amount (Token B) provided for liquidity amount", )); } @@ -467,7 +599,7 @@ pub fn remove_liquidity( .checked_sub(remove_liquidity_amount) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "liquidity_pool_supply - delta_lp underflows", ) })?; @@ -476,7 +608,7 @@ pub fn remove_liquidity( .checked_sub(withdraw_amount_a) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_a - withdraw_amount_a underflows", ) })?; @@ -485,7 +617,7 @@ pub fn remove_liquidity( .checked_sub(withdraw_amount_b) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_b - withdraw_amount_b underflows", ) })?; @@ -499,6 +631,7 @@ pub fn remove_liquidity( } /// Result of either exact-input or exact-output swap quoting. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SwapQuote { /// Direction relative to stored pool order. @@ -556,13 +689,13 @@ pub fn swap_exact_input( )?; if effective_amount_in == 0 { return Err(QuoteError::new( - "effective_swap_input_zero", + QuoteErrorCode::EffectiveSwapInputZero, "Effective swap amount should be nonzero", )); } let reserve_plus_effective = reserve_in.checked_add(effective_amount_in).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve + effective_amount_in overflows u128", ) })?; @@ -574,13 +707,13 @@ pub fn swap_exact_input( )?; if amount_out < minimum_amount_out { return Err(QuoteError::new( - "swap_output_below_minimum", + QuoteErrorCode::SwapOutputBelowMinimum, "Withdraw amount is less than minimal amount out", )); } if amount_out == 0 { return Err(QuoteError::new( - "swap_output_zero", + QuoteErrorCode::SwapOutputZero, "Withdraw amount should be nonzero", )); } @@ -621,7 +754,7 @@ pub fn swap_exact_output( validate_swap_pool(pool, vault_a_balance, vault_b_balance)?; if exact_amount_out == 0 { return Err(QuoteError::new( - "exact_output_zero", + QuoteErrorCode::ExactOutputZero, "Exact amount out must be nonzero", )); } @@ -629,13 +762,16 @@ pub fn swap_exact_output( let (reserve_in, reserve_out) = directional_reserves(pool, direction); if exact_amount_out >= reserve_out { return Err(QuoteError::new( - "exact_output_exceeds_reserve", + QuoteErrorCode::ExactOutputExceedsReserve, "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") + QuoteError::new( + QuoteErrorCode::ArithmeticOverflow, + "reserve_out - amount_out underflows", + ) })?; let minimum_effective_input = checked_ceil( reserve_in, @@ -652,7 +788,7 @@ pub fn swap_exact_output( )?; if amount_in > maximum_amount_in { return Err(QuoteError::new( - "required_input_exceeds_maximum", + QuoteErrorCode::RequiredInputExceedsMaximum, "Required input exceeds maximum amount in", )); } @@ -673,6 +809,7 @@ pub fn swap_exact_output( } /// Result of synchronizing stored reserves to vault balances. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SyncReservesQuote { /// Untracked token-A balance incorporated into the reserve. @@ -692,7 +829,7 @@ pub fn sync_reserves( ensure_supported_fee_tier(pool.fees)?; if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "liquidity_supply_below_minimum", + QuoteErrorCode::LiquiditySupplyBelowMinimum, "Pool liquidity supply is below minimum liquidity", )); } @@ -705,13 +842,13 @@ pub fn sync_reserves( )?; let donated_amount_a = vault_a_balance.checked_sub(pool.reserve_a).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "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", + QuoteErrorCode::ArithmeticOverflow, "vault B balance - reserve B underflows", ) })?; @@ -724,6 +861,7 @@ pub fn sync_reserves( } /// Values used to initialize a pool-backed TWAP oracle price account. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct OraclePriceAccountQuote { /// Pool token A, used as the oracle base asset. @@ -743,20 +881,20 @@ pub fn create_oracle_price_account( ) -> Result { if window_duration < u64::from(OBSERVATIONS_CAPACITY) { return Err(QuoteError::new( - "oracle_window_too_short", + QuoteErrorCode::OracleWindowTooShort, "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", + QuoteErrorCode::ReserveAZero, "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", + QuoteErrorCode::OraclePriceZero, "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)", )); } @@ -774,7 +912,7 @@ fn ensure_supported_fee_tier(fee_bps: u128) -> Result<(), QuoteError> { Ok(()) } else { Err(QuoteError::new( - "unsupported_fee_tier", + QuoteErrorCode::UnsupportedFeeTier, "Fee tier must be one of 1, 5, 30, or 100 basis points", )) } @@ -789,13 +927,13 @@ fn ensure_vault_balances( ) -> Result<(), QuoteError> { if vault_a_balance < pool.reserve_a { return Err(QuoteError::new( - "vault_a_balance_below_reserve", + QuoteErrorCode::VaultABalanceBelowReserve, vault_a_message, )); } if vault_b_balance < pool.reserve_b { return Err(QuoteError::new( - "vault_b_balance_below_reserve", + QuoteErrorCode::VaultBBalanceBelowReserve, vault_b_message, )); } @@ -811,7 +949,7 @@ fn validate_swap_pool( ensure_supported_fee_tier(pool.fees)?; if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "liquidity_supply_below_minimum", + QuoteErrorCode::LiquiditySupplyBelowMinimum, "Pool liquidity supply is below minimum liquidity", )); } @@ -840,7 +978,7 @@ fn finish_swap_quote( ) -> Result { let fee_amount = amount_in.checked_sub(effective_amount_in).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "gross input - effective input underflows", ) })?; @@ -848,13 +986,13 @@ fn finish_swap_quote( SwapDirection::AToB => ( pool.reserve_a.checked_add(amount_in).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_a + deposit_a overflows u128", ) })?, pool.reserve_b.checked_sub(amount_out).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_b + deposit_b - withdraw_b underflows", ) })?, @@ -862,13 +1000,13 @@ fn finish_swap_quote( SwapDirection::BToA => ( pool.reserve_a.checked_sub(amount_out).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_a + deposit_a - withdraw_a underflows", ) })?, pool.reserve_b.checked_add(amount_in).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_b + deposit_b overflows u128", ) })?, @@ -886,9 +1024,12 @@ fn finish_swap_quote( } fn fee_multiplier(fee_bps: u128) -> Result { - FEE_BPS_DENOMINATOR - .checked_sub(fee_bps) - .ok_or_else(|| QuoteError::new("unsupported_fee_tier", "fee_bps exceeds fee denominator")) + FEE_BPS_DENOMINATOR.checked_sub(fee_bps).ok_or_else(|| { + QuoteError::new( + QuoteErrorCode::UnsupportedFeeTier, + "fee_bps exceeds fee denominator", + ) + }) } fn pool_update( @@ -898,7 +1039,7 @@ fn pool_update( ) -> Result { if reserve_a == 0 { return Err(QuoteError::new( - "reserve_a_zero", + QuoteErrorCode::ReserveAZero, "spot_price_q64_64: reserve_base must be non-zero", )); } @@ -918,7 +1059,7 @@ fn checked_floor( overflow_message: &'static str, ) -> Result { checked_mul_div_floor(left, right, denominator) - .ok_or_else(|| QuoteError::new("arithmetic_overflow", overflow_message)) + .ok_or_else(|| QuoteError::new(QuoteErrorCode::ArithmeticOverflow, overflow_message)) } fn checked_ceil( @@ -928,5 +1069,5 @@ fn checked_ceil( overflow_message: &'static str, ) -> Result { checked_mul_div_ceil(left, right, denominator) - .ok_or_else(|| QuoteError::new("arithmetic_overflow", overflow_message)) + .ok_or_else(|| QuoteError::new(QuoteErrorCode::ArithmeticOverflow, overflow_message)) } diff --git a/programs/amm/tests/quote_api.rs b/programs/amm/tests/quote_api.rs index c8879d7a..ccd77081 100644 --- a/programs/amm/tests/quote_api.rs +++ b/programs/amm/tests/quote_api.rs @@ -1,9 +1,9 @@ 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, + core::{ + spot_price_q64_64, PoolDefinition, FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, + FEE_TIER_BPS_5, MINIMUM_LIQUIDITY, SUPPORTED_FEE_TIERS, }, + quote::{self, PairOrder, PoolUpdate, QuoteErrorCode, SwapDirection}, }; use nssa_core::account::AccountId; use twap_oracle_core::OBSERVATIONS_CAPACITY; @@ -30,39 +30,63 @@ fn pool() -> PoolDefinition { } } +fn assert_pool_update( + update: PoolUpdate, + liquidity_pool_supply: u128, + reserve_a: u128, + reserve_b: u128, +) { + assert_eq!(update.liquidity_pool_supply, liquidity_pool_supply); + assert_eq!(update.reserve_a, reserve_a); + assert_eq!(update.reserve_b, reserve_b); + assert_eq!( + update.spot_price_q64_64, + spot_price_q64_64(reserve_a, reserve_b) + ); +} + #[test] fn create_pool_quotes_locked_and_user_liquidity() { + let quoted = quote::create_pool(4_000, 9_000, FEE_TIER_BPS_30) + .expect("valid initial liquidity should quote"); + + assert_pool_update(quoted.pool, 6_000, 4_000, 9_000); + assert_eq!(quoted.locked_liquidity, MINIMUM_LIQUIDITY); + assert_eq!(quoted.user_liquidity, 5_000); +} + +#[test] +fn create_pool_quote_preserves_spot_price_saturation() { + let quoted = quote::create_pool(1, u128::MAX, FEE_TIER_BPS_30) + .expect("spot-price range overflow should saturate, not reject the amount quote"); + + assert_eq!(quoted.pool.spot_price_q64_64, u128::MAX); +} + +#[test] +fn supported_fee_tiers_are_exposed_as_a_slice() { + let tiers: &[u128] = SUPPORTED_FEE_TIERS; + 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, - }) + tiers, + &[ + FEE_TIER_BPS_1, + FEE_TIER_BPS_5, + FEE_TIER_BPS_30, + FEE_TIER_BPS_100, + ] ); } #[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), - }, - }) - ); + let quoted = quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 399) + .expect("valid proportional deposit should quote"); + + assert_eq!(quoted.actual_amount_a, 200); + assert_eq!(quoted.actual_amount_b, 100); + assert_eq!(quoted.liquidity_to_mint, 400); + assert_pool_update(quoted.pool, 2_400, 1_200, 600); } #[test] @@ -86,83 +110,52 @@ fn preview_helpers_return_amounts_before_client_slippage_policy() { #[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), - }, - }) - ); + let quoted = quote::remove_liquidity(&pool(), 1_000, 500, 250, 125) + .expect("valid proportional withdrawal should quote"); + + assert_eq!(quoted.withdraw_amount_a, 250); + assert_eq!(quoted.withdraw_amount_b, 125); + assert_eq!(quoted.liquidity_to_burn, 500); + assert_pool_update(quoted.pool, 1_500, 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), - }, - }; + let exact_input = quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::AToB, 100, 45) + .expect("valid exact-input trade should quote"); + let exact_output = quote::swap_exact_output(&pool(), 1_000, 500, SwapDirection::AToB, 45, 100) + .expect("valid exact-output trade should quote"); - 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) - ); + assert_eq!(exact_input, exact_output); + assert_eq!(exact_input.direction, SwapDirection::AToB); + assert_eq!(exact_input.amount_in, 100); + assert_eq!(exact_input.effective_amount_in, 99); + assert_eq!(exact_input.fee_amount, 1); + assert_eq!(exact_input.amount_out, 45); + assert_pool_update(exact_input.pool, 2_000, 1_100, 455); } #[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), - }, - }) - ); + let quoted = quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::BToA, 100, 165) + .expect("valid reverse trade should quote"); + + assert_eq!(quoted.direction, SwapDirection::BToA); + assert_eq!(quoted.amount_in, 100); + assert_eq!(quoted.effective_amount_in, 99); + assert_eq!(quoted.fee_amount, 1); + assert_eq!(quoted.amount_out, 165); + assert_pool_update(quoted.pool, 2_000, 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), - }, - }) - ); + let quoted = quote::sync_reserves(&pool(), 1_100, 550) + .expect("vault donations above reserves should quote"); + + assert_eq!(quoted.donated_amount_a, 100); + assert_eq!(quoted.donated_amount_b, 50); + assert_pool_update(quoted.pool, 2_000, 1_100, 550); } #[test] @@ -204,6 +197,7 @@ 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.kind(), QuoteErrorCode::MintedLiquidityBelowMinimum); assert_eq!(error.code(), "minted_liquidity_below_minimum"); assert_eq!( error.message(), @@ -211,6 +205,110 @@ fn quote_errors_expose_stable_machine_codes() { ); } +#[test] +fn quote_error_codes_have_stable_strings() { + let cases = [ + (QuoteErrorCode::ArithmeticOverflow, "arithmetic_overflow"), + (QuoteErrorCode::DepositAmountZero, "deposit_amount_zero"), + ( + QuoteErrorCode::EffectiveSwapInputZero, + "effective_swap_input_zero", + ), + ( + QuoteErrorCode::ExactOutputExceedsReserve, + "exact_output_exceeds_reserve", + ), + (QuoteErrorCode::ExactOutputZero, "exact_output_zero"), + ( + QuoteErrorCode::InitialLiquidityTooLow, + "initial_liquidity_too_low", + ), + ( + QuoteErrorCode::InputTokenNotInPool, + "input_token_not_in_pool", + ), + ( + QuoteErrorCode::InvalidLiquidityAccount, + "invalid_liquidity_account", + ), + ( + QuoteErrorCode::LiquiditySupplyBelowMinimum, + "liquidity_supply_below_minimum", + ), + (QuoteErrorCode::MaximumDepositZero, "maximum_deposit_zero"), + ( + QuoteErrorCode::MinimumLiquidityZero, + "minimum_liquidity_zero", + ), + ( + QuoteErrorCode::MinimumWithdrawalZero, + "minimum_withdrawal_zero", + ), + ( + QuoteErrorCode::MintedLiquidityBelowMinimum, + "minted_liquidity_below_minimum", + ), + (QuoteErrorCode::MintedLiquidityZero, "minted_liquidity_zero"), + (QuoteErrorCode::OraclePriceZero, "oracle_price_zero"), + ( + QuoteErrorCode::OracleWindowTooShort, + "oracle_window_too_short", + ), + ( + QuoteErrorCode::PoolContainsOnlyLockedLiquidity, + "pool_contains_only_locked_liquidity", + ), + ( + QuoteErrorCode::RemoveAmountExceedsUnlockedLiquidity, + "remove_amount_exceeds_unlocked_liquidity", + ), + ( + QuoteErrorCode::RemoveAmountExceedsUserBalance, + "remove_amount_exceeds_user_balance", + ), + ( + QuoteErrorCode::RemoveLiquidityAmountZero, + "remove_liquidity_amount_zero", + ), + ( + QuoteErrorCode::RequiredInputExceedsMaximum, + "required_input_exceeds_maximum", + ), + (QuoteErrorCode::ReserveAZero, "reserve_a_zero"), + (QuoteErrorCode::ReserveZero, "reserve_zero"), + ( + QuoteErrorCode::SwapOutputBelowMinimum, + "swap_output_below_minimum", + ), + (QuoteErrorCode::SwapOutputZero, "swap_output_zero"), + (QuoteErrorCode::TokenAAmountZero, "token_a_amount_zero"), + (QuoteErrorCode::TokenBAmountZero, "token_b_amount_zero"), + (QuoteErrorCode::TokenPairNotInPool, "token_pair_not_in_pool"), + (QuoteErrorCode::UnsupportedFeeTier, "unsupported_fee_tier"), + ( + QuoteErrorCode::VaultABalanceBelowReserve, + "vault_a_balance_below_reserve", + ), + ( + QuoteErrorCode::VaultBBalanceBelowReserve, + "vault_b_balance_below_reserve", + ), + ( + QuoteErrorCode::WithdrawalABelowMinimum, + "withdrawal_a_below_minimum", + ), + ( + QuoteErrorCode::WithdrawalBBelowMinimum, + "withdrawal_b_below_minimum", + ), + ]; + + assert_eq!(cases.len(), 33); + for (kind, expected) in cases { + assert_eq!(kind.as_str(), expected); + } +} + #[test] fn exact_quotes_apply_instruction_slippage_guards() { let add = quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 401) From ead21c284678aaf099c49d3bd3ec1974d07d344c Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 15:12:22 -0300 Subject: [PATCH 3/8] feat(amm): complete shared transaction client --- Cargo.lock | 1 + programs/amm/client/Cargo.toml | 1 + programs/amm/client/README.md | 14 +- programs/amm/client/docs/wire-api.md | 153 ++- programs/amm/client/include/amm_client.h | 26 +- programs/amm/client/src/discovery.rs | 628 +++++++++ programs/amm/client/src/ffi.rs | 7 +- programs/amm/client/src/intent.rs | 502 +++++++ programs/amm/client/src/lib.rs | 23 + programs/amm/client/src/plan.rs | 21 + programs/amm/client/src/slippage.rs | 25 +- programs/amm/client/src/transaction.rs | 1196 +++++++++++++++++ programs/amm/client/src/wire.rs | 1007 +++++++++++++- .../amm/client/tests/discovery_contract.rs | 396 ++++++ programs/amm/client/tests/ffi_contract.rs | 6 +- programs/amm/client/tests/intent_contract.rs | 244 ++++ programs/amm/client/tests/plan_contract.rs | 19 + programs/amm/client/tests/quote_contract.rs | 60 +- .../amm/client/tests/transaction_contract.rs | 922 +++++++++++++ .../tests/wire_discovery_intent_contract.rs | 530 ++++++++ .../amm/client/tests/wire_prepare_contract.rs | 2 +- .../client/tests/wire_transaction_contract.rs | 565 ++++++++ programs/amm/core/src/lib.rs | 68 +- 23 files changed, 6353 insertions(+), 63 deletions(-) create mode 100644 programs/amm/client/src/discovery.rs create mode 100644 programs/amm/client/src/intent.rs create mode 100644 programs/amm/client/src/transaction.rs create mode 100644 programs/amm/client/tests/discovery_contract.rs create mode 100644 programs/amm/client/tests/intent_contract.rs create mode 100644 programs/amm/client/tests/transaction_contract.rs create mode 100644 programs/amm/client/tests/wire_discovery_intent_contract.rs create mode 100644 programs/amm/client/tests/wire_transaction_contract.rs diff --git a/Cargo.lock b/Cargo.lock index 76b3b1e9..7a625bbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ name = "amm_client" version = "0.1.0" dependencies = [ + "alloy-primitives", "amm_core", "amm_program", "clock_core", diff --git a/programs/amm/client/Cargo.toml b/programs/amm/client/Cargo.toml index 0c8097ae..f7b13ebd 100644 --- a/programs/amm/client/Cargo.toml +++ b/programs/amm/client/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib", "rlib"] workspace = true [dependencies] +alloy-primitives = { version = "1", default-features = false } amm_core = { path = "../core" } amm_program = { path = ".." } clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" } diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md index ab58b8a4..e5b19580 100644 --- a/programs/amm/client/README.md +++ b/programs/amm/client/README.md @@ -12,10 +12,16 @@ adapter responsibilities. - `quote` validates fetched config, pool, vault, token-definition, LP-definition, and user-holding snapshots before delegating calculations to `amm_program::quote`. +- `discovery` derives config and complete pair read manifests, then classifies raw pair snapshots + as missing or active without performing network I/O. +- `intent` prepares canonical opening amounts and caller/stored order mappings with integer-only + protocol math. - `slippage` converts validated quotes into integer-only instruction guards. Minimum guards round down, maximum guards round up, and checked overflow returns a typed error. - `plan` covers all ten guest instructions and returns the canonical instruction plus ordered account roles and writable, signer, and init flags. +- `transaction` binds complete snapshots, canonical quotes, exact plans, caller amounts, wallet + prerequisites, and a refreshable quote commitment for create/add/remove/swap tasks. - `TransactionPlan::instruction_data` serializes its `amm_core::Instruction` with `risc0_zkvm::serde::to_vec`. - `wire` exposes lossless JSON adapters for non-Rust hosts. @@ -41,8 +47,8 @@ oracle-price initialization. `prepare_create_pool`, `prepare_add_liquidity`, `prepare_remove_liquidity`, `prepare_swap_exact_input`, and `prepare_swap_exact_output` return a quote plus the exact amount fields to pass to the corresponding planner. Consumers choose a slippage tolerance in basis points but do not calculate chain guards. Prepared add-liquidity maxima -use the quote's actual deposits, so execution cannot spend above the displayed/current quote even -when the caller supplied a lopsided pair of caps. +preserve caller caps because substituting rounded actual deposits can change execution's +proportional integer quote. The task-level transaction API validates funding against those caps. ## Compatibility assumption @@ -68,4 +74,6 @@ Every call returns an owned JSON envelope. Release it exactly once with `amm_cli Raw `u128` and `u64` values cross JSON as decimal strings. Account IDs use their canonical base58 display form, program IDs use eight JSON `u32` words, account data uses hexadecimal, and encoded instruction words remain JSON `u32` numbers. No JavaScript `Number` conversion is required for -chain amounts or deadlines. +chain amounts or deadlines. Plan JSON also includes typed `instructionArgs`, derived directly from +the same `amm_core::Instruction` encoded in `instructionWords`. Both C entrypoints accept the five +snapshot-bound `prepare_*_transaction` operations. diff --git a/programs/amm/client/docs/wire-api.md b/programs/amm/client/docs/wire-api.md index 46b9e379..8eef6697 100644 --- a/programs/amm/client/docs/wire-api.md +++ b/programs/amm/client/docs/wire-api.md @@ -3,16 +3,20 @@ The C ABI accepts one tagged JSON object and returns one envelope: ```json -{"ok":true,"value":{}} +{"schema":"amm-client.v1","ok":true,"value":{"schema":"amm-client.v1"}} ``` ```json -{"ok":false,"error":{"code":"invalid_request","message":"..."}} +{"schema":"amm-client.v1","ok":false,"error":{"code":"invalid_request","message":"..."}} ``` +Requests may include `"schema":"amm-client.v1"`. Schema-less requests remain accepted for +compatibility. Every successful wire value and every C envelope identifies the response schema. + All `u128` amounts, reserves, supplies, fees, nonces, and balances are unsigned decimal strings. All `u64` windows and deadlines are also decimal strings. Program IDs are arrays of eight `u32` -words. Account IDs are base58 strings. Account `data` is an even-length hexadecimal string. +words. Signed ticks are decimal strings. Account IDs are base58 strings. Account `data` is an +even-length hexadecimal string. ## Shared inputs @@ -73,6 +77,24 @@ Existing-pool quote operations include these top-level state fields: } ``` +Discovery and task-transaction operations use the complete caller-ordered pair read set: + +```json +{ + "snapshots": { + "pool": { "...": "account snapshot" }, + "firstTokenDefinition": { "...": "account snapshot" }, + "secondTokenDefinition": { "...": "account snapshot" }, + "firstTokenVault": { "...": "account snapshot" }, + "secondTokenVault": { "...": "account snapshot" }, + "liquidityDefinition": { "...": "account snapshot" }, + "lpLockHolding": { "...": "account snapshot" }, + "currentTick": { "...": "account snapshot" }, + "clock": { "...": "account snapshot" } + } +} +``` + ## Plan operations Send requests to `amm_client_plan` or `wire::plan_json`. @@ -89,12 +111,23 @@ Send requests to `amm_client_plan` or `wire::plan_json`. | `swap_exact_input` | `context`, `pool`, `userInputHolding`, `userOutputHolding`, `swapAmountIn`, `minAmountOut`, `deadline` | | `swap_exact_output` | `context`, `pool`, `userInputHolding`, `userOutputHolding`, `exactAmountOut`, `maxAmountIn`, `deadline` | | `sync_reserves` | `context`, `pool` | +| `prepare_create_pool_transaction` | same task request documented under Task transactions | +| `prepare_add_liquidity_transaction` | same task request documented under Task transactions | +| `prepare_remove_liquidity_transaction` | same task request documented under Task transactions | +| `prepare_swap_exact_input_transaction` | same task request documented under Task transactions | +| `prepare_swap_exact_output_transaction` | same task request documented under Task transactions | A successful plan value contains the following fields (`instructionWords` is abbreviated here): ```json { "instruction": "add_liquidity", + "instructionArgs": { + "minAmountLiquidity": "99", + "maxAmountToAddTokenA": "400", + "maxAmountToAddTokenB": "100", + "deadline": "1900000000000" + }, "programId": [0, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { @@ -105,22 +138,36 @@ A successful plan value contains the following fields (`instructionWords` is abb "init": false } ], + "affectedAccountIds": ["base58-account-id"], "instructionWords": [5] } ``` The real `instructionWords` array contains the complete encoding produced directly from the -canonical `amm_core::Instruction` with RISC Zero Serde. Account rows follow guest/IDL order. +canonical `amm_core::Instruction` with RISC Zero Serde. `instructionArgs` is exhaustively derived +from that same typed instruction, so C++/QML consumers do not decode RISC Zero Serde. Its `u128` +and `u64` fields are decimal strings, optional fields are JSON `null`, and account IDs are base58 +strings. Account rows follow guest/IDL order. ## Quote operations -Send requests to `amm_client_quote` or `wire::quote_json`. Except `protocol_constants`, -`create_pool`, and `prepare_create_pool`, every operation below also includes the existing-pool -quote state described above. +Send requests to `amm_client_quote` or `wire::quote_json`. Pool economic operations use the +existing-pool quote state described above. Discovery, opening intent, and task-transaction +operations use the fields shown in this table and the sections below. | `operation` | Additional fields | |---|---| | `protocol_constants` | none; returns decimal-string `minimumLiquidity`, `feeBpsDenominator`, `slippageBpsDenominator`, and `supportedFeeTiers` | +| `derive_config_id` | `ammProgramId` | +| `inspect_config` | `ammProgramId`, raw `config` snapshot | +| `canonical_pair` | `firstTokenDefinitionId`, `secondTokenDefinitionId` | +| `derive_pair_read_manifest` | `ammProgramId`, raw `config`, `firstTokenDefinitionId`, `secondTokenDefinitionId` | +| `inspect_pair` | fields from `derive_pair_read_manifest` plus complete `snapshots` | +| `prepare_minimum_opening_pair` | `desiredPriceQ64_64`, `feeBps` | +| `prepare_opening_from_token_a` | `tokenAAmount`, `desiredPriceQ64_64`, `feeBps` | +| `prepare_opening_from_token_b` | `tokenBAmount`, `desiredPriceQ64_64`, `feeBps` | +| `validate_explicit_opening_pair` | `tokenAAmount`, `tokenBAmount`, `desiredPriceQ64_64`, `feeBps` | +| `prepare_caller_opening_pair` | caller token IDs, desired price, fee, and tagged `intent` described below | | `pair_order` | `firstTokenDefinitionId`, `secondTokenDefinitionId` | | `create_pool` | `ammProgramId`, `config`, `tokenADefinition`, `tokenBDefinition`, `tokenAAmount`, `tokenBAmount`, `feeBps` | | `prepare_create_pool` | same fields as `create_pool`; returns quote plus `NewDefinition` instruction arguments | @@ -138,6 +185,11 @@ quote state described above. | `swap_exact_output` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `exactAmountOut`, `maximumAmountIn` | | `sync_reserves` | no additional fields | | `create_oracle_price_account` | `windowDuration` | +| `prepare_create_pool_transaction` | task-transaction fields below | +| `prepare_add_liquidity_transaction` | task-transaction fields below | +| `prepare_remove_liquidity_transaction` | task-transaction fields below | +| `prepare_swap_exact_input_transaction` | task-transaction fields below | +| `prepare_swap_exact_output_transaction` | task-transaction fields below | Quote values use these result shapes: @@ -152,6 +204,86 @@ Quote values use these result shapes: A `pool` result contains decimal-string `liquidityPoolSupply`, `reserveA`, `reserveB`, and `spotPriceQ64_64` fields. +## Discovery, inspection, and opening intents + +Discovery functions derive IDs only; adapters fetch the returned accounts and submit raw +snapshots for inspection. `inspect_pair` returns `status` as `missing` or `active`. Missing output +contains the read manifest, caller-ordered definitions, vault lifecycle states, and clock. Active +output contains the manifest, `callerOrder`, stored token/vault/LP IDs, reserves, vault balances, +LP supply, fee, stored Q64.64 spot price, current tick, and clock. Numeric protocol fields remain +strings. + +`prepare_caller_opening_pair` accepts caller token order without reproducing canonical ordering: + +```json +{ + "operation": "prepare_caller_opening_pair", + "firstTokenDefinitionId": "base58-account-id", + "secondTokenDefinitionId": "base58-account-id", + "desiredPriceQ64_64": "18446744073709551616", + "feeBps": "30", + "intent": { "kind": "first_amount", "amount": "2000" } +} +``` + +Other intent shapes are `{ "kind":"minimum" }`, +`{ "kind":"second_amount", "amount":"..." }`, and +`{ "kind":"explicit", "firstAmount":"...", "secondAmount":"..." }`. The result includes +`callerOrder`, caller `firstAmount`/`secondAmount`, and the canonical stored opening quote and +amounts. + +## Task transactions + +The five snapshot-bound task operations are accepted by both `amm_client_plan`/`wire::plan_json` +and `amm_client_quote`/`wire::quote_json`. Every request includes `ammProgramId`, raw `config`, the +complete caller-ordered `snapshots`, and decimal-string `deadline`. + +| `operation` | Additional fields | +|---|---| +| `prepare_create_pool_transaction` | caller token IDs, `firstTokenHolding`, `secondTokenHolding`, `liquidityHolding`, `firstAmount`, `secondAmount`, `feeBps` | +| `prepare_add_liquidity_transaction` | caller token IDs and holdings, `maxFirstAmount`, `maxSecondAmount`, `slippageBps`, optional `expectedFeeBps` | +| `prepare_remove_liquidity_transaction` | caller token IDs and holdings, `removeLiquidityAmount`, `slippageBps`, optional `expectedFeeBps` | +| `prepare_swap_exact_input_transaction` | input/output token IDs and holdings, `amountIn`, `slippageBps`, optional `expectedFeeBps` | +| `prepare_swap_exact_output_transaction` | input/output token IDs and holdings, `exactAmountOut`, `slippageBps`, optional `expectedFeeBps` | + +Successful task output contains: + +```json +{ + "operation": "swap_exact_output", + "quote": {}, + "callerAmounts": { "first": "101", "second": "100" }, + "plan": { + "instruction": "swap_exact_output", + "instructionArgs": { + "exactAmountOut": "100", + "maxAmountIn": "102", + "deadline": "1900000000000" + }, + "instructionWords": [] + }, + "quoteCommitment": "64-lowercase-hex-characters", + "affectedAccountIds": ["base58-account-id"], + "walletPrerequisites": { + "signerAccountIds": ["base58-account-id"], + "freshAccountIds": [], + "funding": [{ + "holdingAccountId": "base58-account-id", + "tokenDefinitionId": "base58-account-id", + "available": "1000", + "required": "102" + }] + }, + "deadline": "1900000000000", + "poolSpotChangeBps": "42" +} +``` + +`poolSpotChangeBps` is `null` for non-swap tasks. Add-liquidity funding requirements use the +caller caps. Exact-output swap funding uses the plan's slippage-adjusted `maxAmountIn`. Hosts +should refresh snapshots, prepare again, compare `quoteCommitment`, and submit only the refreshed +plan. + ## Prepared instruction arguments The five `prepare_*` operations return the economic result under `quote` and decimal-string chain @@ -171,9 +303,10 @@ quotes. Maximum guards use integer ceil rounding. A maximum above `u128` returns `slippage_bound_overflow`; an out-of-range tolerance returns `slippage_tolerance_out_of_range`. This calculation runs only in the Rust client, never in JavaScript or QML. -Prepared add-liquidity maximums are the quote's `actualAmountA` and `actualAmountB`, not the original -possibly lopsided caps. The exact quote is rerun with those fields before they are returned. This -keeps the eventual plan from spending above the displayed/current quoted deposits. +Prepared add-liquidity maximums preserve the original caller caps. Replacing them with rounded +`actualAmountA` and `actualAmountB` can change the program quote when reserve ratios are not +divisible, because execution performs proportional integer rounding again. Funding prerequisites +therefore cover the caller caps while display amounts remain the canonical quote's actual deposit. ## Ownership and failures diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h index 24dc84c2..4dc680a0 100644 --- a/programs/amm/client/include/amm_client.h +++ b/programs/amm/client/include/amm_client.h @@ -9,28 +9,32 @@ extern "C" { * Accepts a tagged UTF-8 JSON request and returns an owned UTF-8 JSON envelope. * Supported operation tags: initialize, update_config, create_price_observations, * create_oracle_price_account, create_pool, add_liquidity, remove_liquidity, - * swap_exact_input, swap_exact_output, and sync_reserves. + * swap_exact_input, swap_exact_output, sync_reserves, and the five + * prepare_*_transaction task operations documented in docs/wire-api.md. * Release the result with amm_client_free. */ char *amm_client_plan(const char *request_json); /* * Accepts a tagged UTF-8 JSON request and returns an owned UTF-8 JSON envelope. - * Supported operation tags: protocol_constants, pair_order, create_pool, - * prepare_create_pool, preview_add_liquidity, prepare_add_liquidity, add_liquidity, - * preview_remove_liquidity, prepare_remove_liquidity, remove_liquidity, - * preview_swap_exact_input, prepare_swap_exact_input, swap_exact_input, - * preview_swap_exact_output, prepare_swap_exact_output, swap_exact_output, - * sync_reserves, and create_oracle_price_account. + * Supported operation tags include protocol constants; config and pair discovery; + * pair inspection; caller-order opening preparation; economic quote/preparation + * operations; and prepare_create_pool_transaction, + * prepare_add_liquidity_transaction, prepare_remove_liquidity_transaction, + * prepare_swap_exact_input_transaction, and + * prepare_swap_exact_output_transaction. See docs/wire-api.md for fields. * Release the result with amm_client_free. */ char *amm_client_quote(const char *request_json); /* - * Raw u128 and u64 values are unsigned decimal JSON strings. Program IDs and - * instruction words are JSON u32 arrays. Account IDs are base58 strings and - * account data is hexadecimal. Responses use {"ok":true,"value":...} or - * {"ok":false,"error":{"code":...,"message":...}}. + * Raw u128, u64, and signed tick values are decimal JSON strings. Program IDs + * and instruction words are JSON u32 arrays. Account IDs are base58 strings and + * account data is hexadecimal. Requests may carry schema "amm-client.v1"; + * schema-less legacy requests remain accepted. Responses use + * {"schema":"amm-client.v1","ok":true,"value":...} or the same envelope + * with ok=false and error={"code":...,"message":...}. Plan values contain + * typed instructionArgs as well as exact RISC Zero instructionWords. */ /* diff --git a/programs/amm/client/src/discovery.rs b/programs/amm/client/src/discovery.rs new file mode 100644 index 00000000..c7269f81 --- /dev/null +++ b/programs/amm/client/src/discovery.rs @@ -0,0 +1,628 @@ +//! Deterministic AMM account discovery and pair lifecycle inspection. +//! +//! These functions derive the complete protocol read set, then validate caller-supplied snapshots. +//! They perform no RPC, signing, submission, or deployed-program compatibility lookup. + +use amm_core::{ + canonical_token_pair, compute_config_pda, compute_liquidity_token_pda, + compute_lp_lock_holding_pda, compute_pool_pda, compute_vault_pda, spot_price_q64_64, + MINIMUM_LIQUIDITY, +}; +use amm_program::quote as program_quote; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; +use token_core::TokenHolding; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +use crate::{ + plan::AmmContext, + quote::{ + AccountSnapshot, ValidatedFungibleDefinition, ValidatedFungibleHolding, + ValidatedPoolSnapshot, + }, + ClientError, +}; + +/// Deterministic pre-pool token order used by AMM pool PDA derivation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CanonicalPair { + token_a_id: AccountId, + token_b_id: AccountId, +} + +impl CanonicalPair { + /// Returns canonical token A, whose raw account-ID bytes sort after token B. + #[must_use] + pub const fn token_a_id(&self) -> AccountId { + self.token_a_id + } + + /// Returns canonical token B. + #[must_use] + pub const fn token_b_id(&self) -> AccountId { + self.token_b_id + } +} + +/// One caller-named token definition and its deterministic pool vault. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TokenReadManifest { + definition_id: AccountId, + vault_id: AccountId, +} + +impl TokenReadManifest { + /// Returns the token definition account ID. + #[must_use] + pub const fn definition_id(&self) -> AccountId { + self.definition_id + } + + /// Returns the pool vault derived for this token definition. + #[must_use] + pub const fn vault_id(&self) -> AccountId { + self.vault_id + } +} + +/// Complete deterministic account read set for inspecting a token pair. +/// +/// `first_token` and `second_token` preserve caller order. Their vaults are therefore named by +/// token rather than by stored pool A/B order, which is unavailable until the pool is decoded. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PairReadManifest { + canonical_pair: CanonicalPair, + first_token: TokenReadManifest, + second_token: TokenReadManifest, + config_id: AccountId, + pool_id: AccountId, + liquidity_definition_id: AccountId, + lp_lock_holding_id: AccountId, + current_tick_id: AccountId, + clock_id: AccountId, +} + +impl PairReadManifest { + /// Returns deterministic pre-pool token order. + #[must_use] + pub const fn canonical_pair(&self) -> CanonicalPair { + self.canonical_pair + } + + /// Returns caller's first token and its derived vault. + #[must_use] + pub const fn first_token(&self) -> TokenReadManifest { + self.first_token + } + + /// Returns caller's second token and its derived vault. + #[must_use] + pub const fn second_token(&self) -> TokenReadManifest { + self.second_token + } + + /// Returns singleton AMM config account ID. + #[must_use] + pub const fn config_id(&self) -> AccountId { + self.config_id + } + + /// Returns pair pool account ID. + #[must_use] + pub const fn pool_id(&self) -> AccountId { + self.pool_id + } + + /// Returns deterministic LP token definition account ID. + #[must_use] + pub const fn liquidity_definition_id(&self) -> AccountId { + self.liquidity_definition_id + } + + /// Returns deterministic permanently locked LP holding account ID. + #[must_use] + pub const fn lp_lock_holding_id(&self) -> AccountId { + self.lp_lock_holding_id + } + + /// Returns pool's TWAP current-tick account ID. + #[must_use] + pub const fn current_tick_id(&self) -> AccountId { + self.current_tick_id + } + + /// Returns canonical one-block clock account ID. + #[must_use] + pub const fn clock_id(&self) -> AccountId { + self.clock_id + } + + /// Looks up a derived vault by token definition ID. + #[must_use] + pub fn vault_id_for(&self, definition_id: AccountId) -> Option { + if definition_id == self.first_token.definition_id { + Some(self.first_token.vault_id) + } else if definition_id == self.second_token.definition_id { + Some(self.second_token.vault_id) + } else { + None + } + } +} + +/// Snapshots fetched from a [`PairReadManifest`]. +#[derive(Clone, Copy)] +pub struct PairReadSnapshots<'a> { + pub pool: &'a AccountSnapshot, + pub first_token_definition: &'a AccountSnapshot, + pub second_token_definition: &'a AccountSnapshot, + pub first_token_vault: &'a AccountSnapshot, + pub second_token_vault: &'a AccountSnapshot, + pub liquidity_definition: &'a AccountSnapshot, + pub lp_lock_holding: &'a AccountSnapshot, + pub current_tick: &'a AccountSnapshot, + pub clock: &'a AccountSnapshot, +} + +/// Validated canonical clock values used by current AMM instructions. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ValidatedClockSnapshot { + block_id: u64, + timestamp: u64, +} + +impl ValidatedClockSnapshot { + #[must_use] + pub const fn block_id(&self) -> u64 { + self.block_id + } + + #[must_use] + pub const fn timestamp(&self) -> u64 { + self.timestamp + } +} + +/// State of a derived vault before its pool exists. +/// +/// Pool creation's chained Token Program transfer accepts either a default destination or an +/// existing fungible holding for the same definition. It does not require every derived vault to +/// be default merely because the pool account is default. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MissingVaultState { + Uninitialized, + ExistingFungible { balance: u128 }, +} + +/// Validated view of a pair whose pool account is still uninitialized. +#[derive(Clone)] +pub struct MissingPairInspection { + manifest: PairReadManifest, + first_token_definition: ValidatedFungibleDefinition, + second_token_definition: ValidatedFungibleDefinition, + first_vault: MissingVaultState, + second_vault: MissingVaultState, + clock: ValidatedClockSnapshot, +} + +impl MissingPairInspection { + #[must_use] + pub const fn manifest(&self) -> PairReadManifest { + self.manifest + } + + #[must_use] + pub const fn first_token_definition(&self) -> &ValidatedFungibleDefinition { + &self.first_token_definition + } + + #[must_use] + pub const fn second_token_definition(&self) -> &ValidatedFungibleDefinition { + &self.second_token_definition + } + + #[must_use] + pub const fn first_vault(&self) -> MissingVaultState { + self.first_vault + } + + #[must_use] + pub const fn second_vault(&self) -> MissingVaultState { + self.second_vault + } + + #[must_use] + pub const fn clock(&self) -> ValidatedClockSnapshot { + self.clock + } +} + +/// Validated current view of an initialized pair. +#[derive(Clone)] +pub struct ActivePairInspection { + manifest: PairReadManifest, + caller_order: program_quote::PairOrder, + pool: ValidatedPoolSnapshot, + lp_lock_holding: ValidatedFungibleHolding, + stored_spot_price_q64_64: u128, + current_tick: CurrentTickAccount, + clock: ValidatedClockSnapshot, +} + +impl ActivePairInspection { + #[must_use] + pub const fn manifest(&self) -> PairReadManifest { + self.manifest + } + + /// Returns caller first/second order relative to stored pool A/B order. + #[must_use] + pub const fn caller_order(&self) -> program_quote::PairOrder { + self.caller_order + } + + /// Returns complete validated stored pool, token-definition, LP-definition, and vault state. + #[must_use] + pub const fn pool(&self) -> &ValidatedPoolSnapshot { + &self.pool + } + + /// Returns the validated holding containing permanently locked minimum liquidity. + #[must_use] + pub const fn lp_lock_holding(&self) -> &ValidatedFungibleHolding { + &self.lp_lock_holding + } + + /// Returns spot price from stored pool reserves as Q64.64 token B per token A. + #[must_use] + pub const fn stored_spot_price_q64_64(&self) -> u128 { + self.stored_spot_price_q64_64 + } + + #[must_use] + pub const fn current_tick(&self) -> &CurrentTickAccount { + &self.current_tick + } + + #[must_use] + pub const fn clock(&self) -> ValidatedClockSnapshot { + self.clock + } +} + +/// Current lifecycle state for a fully inspected pair read set. +#[derive(Clone)] +pub enum PairInspection { + Missing(Box), + Active(Box), +} + +/// Derives the singleton config account without reading network state. +#[must_use] +pub fn derive_config_id(amm_program_id: ProgramId) -> AccountId { + compute_config_pda(amm_program_id) +} + +/// Validates and decodes an AMM config snapshot. +/// +/// The program ID is accepted optimistically as the configured transaction target and PDA +/// namespace. This performs no release, ImageID, or deployment-version check. +pub fn inspect_config( + amm_program_id: ProgramId, + config_snapshot: &AccountSnapshot, +) -> Result { + AmmContext::from_config_account(amm_program_id, config_snapshot) +} + +/// Resolves deterministic pre-pool token order through the same helper used by pool PDA derivation. +pub fn canonical_pair( + first_token_id: AccountId, + second_token_id: AccountId, +) -> Result { + let Some((token_a_id, token_b_id)) = canonical_token_pair(first_token_id, second_token_id) + else { + return Err(ClientError::IdenticalTokenDefinitions); + }; + Ok(CanonicalPair { + token_a_id, + token_b_id, + }) +} + +/// Derives every protocol account needed to inspect a caller-ordered pair. +pub fn derive_pair_read_manifest( + context: &AmmContext, + first_token_id: AccountId, + second_token_id: AccountId, +) -> Result { + let canonical_pair = canonical_pair(first_token_id, second_token_id)?; + let pool_id = compute_pool_pda( + context.amm_program_id, + canonical_pair.token_a_id, + canonical_pair.token_b_id, + ); + + Ok(PairReadManifest { + canonical_pair, + first_token: TokenReadManifest { + definition_id: first_token_id, + vault_id: compute_vault_pda(context.amm_program_id, pool_id, first_token_id), + }, + second_token: TokenReadManifest { + definition_id: second_token_id, + vault_id: compute_vault_pda(context.amm_program_id, pool_id, second_token_id), + }, + config_id: context.config_id(), + pool_id, + liquidity_definition_id: compute_liquidity_token_pda(context.amm_program_id, pool_id), + lp_lock_holding_id: compute_lp_lock_holding_pda(context.amm_program_id, pool_id), + current_tick_id: compute_current_tick_account_pda( + context.twap_oracle_program_id(), + pool_id, + ), + clock_id: CLOCK_01_PROGRAM_ACCOUNT_ID, + }) +} + +/// Validates a pair read set and classifies its pool as missing or active. +pub fn inspect_pair( + context: &AmmContext, + first_token_id: AccountId, + second_token_id: AccountId, + snapshots: PairReadSnapshots<'_>, +) -> Result { + let manifest = derive_pair_read_manifest(context, first_token_id, second_token_id)?; + validate_snapshot_ids(manifest, &snapshots)?; + + let first_token_definition = + ValidatedFungibleDefinition::new(context, snapshots.first_token_definition)?; + let second_token_definition = + ValidatedFungibleDefinition::new(context, snapshots.second_token_definition)?; + let clock = validate_clock(snapshots.clock)?; + + if snapshots.pool.account() == &Account::default() { + validate_uninitialized("liquidity definition", snapshots.liquidity_definition)?; + validate_uninitialized("LP lock holding", snapshots.lp_lock_holding)?; + validate_uninitialized("current tick", snapshots.current_tick)?; + + return Ok(PairInspection::Missing(Box::new(MissingPairInspection { + manifest, + first_vault: validate_missing_vault( + "first token vault", + snapshots.first_token_vault, + context.token_program_id(), + first_token_id, + )?, + second_vault: validate_missing_vault( + "second token vault", + snapshots.second_token_vault, + context.token_program_id(), + second_token_id, + )?, + first_token_definition, + second_token_definition, + clock, + }))); + } + + let stored_pool = + amm_core::PoolDefinition::try_from(&snapshots.pool.account().data).map_err(|_| { + ClientError::InvalidAccountData { + account: "AMM pool", + expected: "PoolDefinition", + } + })?; + let caller_order = program_quote::pair_order(&stored_pool, first_token_id, second_token_id)?; + let (token_a_definition, token_b_definition, vault_a, vault_b) = match caller_order { + program_quote::PairOrder::Stored => ( + snapshots.first_token_definition, + snapshots.second_token_definition, + snapshots.first_token_vault, + snapshots.second_token_vault, + ), + program_quote::PairOrder::Reversed => ( + snapshots.second_token_definition, + snapshots.first_token_definition, + snapshots.second_token_vault, + snapshots.first_token_vault, + ), + }; + let pool = ValidatedPoolSnapshot::new( + context, + snapshots.pool, + token_a_definition, + token_b_definition, + vault_a, + vault_b, + snapshots.liquidity_definition, + )?; + + // Reuse program-owned state validation for fee, minimum LP supply, and vault/reserve + // consistency. Donations are intentionally allowed and remain visible in vault balances. + let _ = crate::quote::sync_reserves(&pool)?; + if pool.pool().reserve_a == 0 || pool.pool().reserve_b == 0 { + return Err(ClientError::Quote { + code: "reserve_zero", + message: "Reserves must be nonzero", + }); + } + let lp_lock_holding = ValidatedFungibleHolding::new( + context, + snapshots.lp_lock_holding, + pool.liquidity_definition(), + )?; + if lp_lock_holding.balance() < MINIMUM_LIQUIDITY { + return Err(ClientError::InvalidAccountData { + account: "LP lock holding", + expected: "fungible LP holding with at least the permanently locked minimum liquidity", + }); + } + + if snapshots.current_tick.account().program_owner != context.twap_oracle_program_id() { + return Err(ClientError::ProgramOwnerMismatch { + account: "current tick", + expected: context.twap_oracle_program_id(), + actual: snapshots.current_tick.account().program_owner, + }); + } + let current_tick = CurrentTickAccount::try_from(&snapshots.current_tick.account().data) + .map_err(|_| ClientError::InvalidAccountData { + account: "current tick", + expected: "CurrentTickAccount", + })?; + let stored_spot_price_q64_64 = spot_price_q64_64(pool.pool().reserve_a, pool.pool().reserve_b); + + Ok(PairInspection::Active(Box::new(ActivePairInspection { + manifest, + caller_order, + pool, + lp_lock_holding, + stored_spot_price_q64_64, + current_tick, + clock, + }))) +} + +fn validate_snapshot_ids( + manifest: PairReadManifest, + snapshots: &PairReadSnapshots<'_>, +) -> Result<(), ClientError> { + for (name, snapshot, expected) in [ + ("pool", snapshots.pool, manifest.pool_id), + ( + "first token definition", + snapshots.first_token_definition, + manifest.first_token.definition_id, + ), + ( + "second token definition", + snapshots.second_token_definition, + manifest.second_token.definition_id, + ), + ( + "first token vault", + snapshots.first_token_vault, + manifest.first_token.vault_id, + ), + ( + "second token vault", + snapshots.second_token_vault, + manifest.second_token.vault_id, + ), + ( + "liquidity definition", + snapshots.liquidity_definition, + manifest.liquidity_definition_id, + ), + ( + "LP lock holding", + snapshots.lp_lock_holding, + manifest.lp_lock_holding_id, + ), + ( + "current tick", + snapshots.current_tick, + manifest.current_tick_id, + ), + ("clock", snapshots.clock, manifest.clock_id), + ] { + if snapshot.account_id() != expected { + return Err(ClientError::AccountIdMismatch { + account: name, + expected, + actual: snapshot.account_id(), + }); + } + } + Ok(()) +} + +fn validate_uninitialized( + account_name: &'static str, + snapshot: &AccountSnapshot, +) -> Result<(), ClientError> { + if snapshot.account() != &Account::default() { + return Err(ClientError::InvalidAccountData { + account: account_name, + expected: "uninitialized account", + }); + } + Ok(()) +} + +fn validate_missing_vault( + account_name: &'static str, + snapshot: &AccountSnapshot, + token_program_id: ProgramId, + expected_definition_id: AccountId, +) -> Result { + if snapshot.account() == &Account::default() { + return Ok(MissingVaultState::Uninitialized); + } + + if snapshot.account().program_owner != token_program_id { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected: token_program_id, + actual: snapshot.account().program_owner, + }); + } + + // Existing recipients must be writable by the Token Program. A default destination remains + // valid because the chained transfer claims it for the Token Program. + let holding = TokenHolding::try_from(&snapshot.account().data).map_err(|_| { + ClientError::InvalidAccountData { + account: account_name, + expected: "TokenHolding", + } + })?; + let TokenHolding::Fungible { + definition_id, + balance, + } = holding + else { + return Err(ClientError::ExpectedFungibleToken { + account: account_name, + }); + }; + if definition_id != expected_definition_id { + return Err(ClientError::TokenDefinitionMismatch { + account: account_name, + expected: expected_definition_id, + actual: definition_id, + }); + } + + Ok(MissingVaultState::ExistingFungible { balance }) +} + +fn validate_clock(snapshot: &AccountSnapshot) -> Result { + let bytes = snapshot.account().data.as_ref(); + if bytes.len() != 16 { + return Err(ClientError::InvalidAccountData { + account: "clock", + expected: "ClockAccountData", + }); + } + let (block_id_bytes, timestamp_bytes) = bytes.split_at(8); + let block_id = u64::from_le_bytes(block_id_bytes.try_into().map_err(|_| { + ClientError::InvalidAccountData { + account: "clock", + expected: "ClockAccountData", + } + })?); + let timestamp = u64::from_le_bytes(timestamp_bytes.try_into().map_err(|_| { + ClientError::InvalidAccountData { + account: "clock", + expected: "ClockAccountData", + } + })?); + + Ok(ValidatedClockSnapshot { + block_id, + timestamp, + }) +} diff --git a/programs/amm/client/src/ffi.rs b/programs/amm/client/src/ffi.rs index ba608bb0..93b4f169 100644 --- a/programs/amm/client/src/ffi.rs +++ b/programs/amm/client/src/ffi.rs @@ -19,6 +19,7 @@ type Operation = fn(Value) -> Result; #[derive(Serialize)] struct Envelope { + schema: &'static str, ok: bool, #[serde(skip_serializing_if = "Option::is_none")] value: Option, @@ -29,6 +30,7 @@ struct Envelope { impl Envelope { fn success(value: Value) -> Self { Self { + schema: wire::WIRE_SCHEMA, ok: true, value: Some(value), error: None, @@ -37,6 +39,7 @@ impl Envelope { fn failure(error: ErrorPayload) -> Self { Self { + schema: wire::WIRE_SCHEMA, ok: false, value: None, error: Some(error), @@ -116,14 +119,14 @@ fn encode_envelope(envelope: &Envelope) -> *mut c_char { let json = match serde_json::to_string(envelope) { Ok(json) => json, Err(_) => String::from( - r#"{"ok":false,"error":{"code":"response_serialization_failed","message":"response serialization failed"}}"#, + r#"{"schema":"amm-client.v1","ok":false,"error":{"code":"response_serialization_failed","message":"response serialization failed"}}"#, ), }; match CString::new(json) { Ok(value) => value.into_raw(), Err(_) => CString::new( - r#"{"ok":false,"error":{"code":"response_contains_nul","message":"response contains NUL"}}"#, + r#"{"schema":"amm-client.v1","ok":false,"error":{"code":"response_contains_nul","message":"response contains NUL"}}"#, ) .map_or(std::ptr::null_mut(), CString::into_raw), } diff --git a/programs/amm/client/src/intent.rs b/programs/amm/client/src/intent.rs new file mode 100644 index 00000000..652a005b --- /dev/null +++ b/programs/amm/client/src/intent.rs @@ -0,0 +1,502 @@ +//! Protocol-aware amount preparation for human-facing AMM intents. + +use std::{error::Error, fmt}; + +use alloy_primitives::U512; +use amm_core::{ + canonical_token_pair, checked_mul_div_ceil, isqrt_product, spot_price_q64_64, PoolDefinition, + MINIMUM_LIQUIDITY, +}; +use amm_program::quote::{ + self as program_quote, CreatePoolQuote, PairOrder, SwapDirection, SwapQuote, +}; +use nssa_core::account::AccountId; + +/// One whole unit in the Q64.64 price representation used by the AMM. +pub const Q64_64_ONE: u128 = 1_u128 << 64; + +/// Failure while turning a caller intent into executable AMM amounts. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IntentError { + /// A caller requested a token paired with itself. + IdenticalTokenDefinitions, + /// A Q64.64 desired price must be nonzero. + ZeroDesiredPrice, + /// An edited token amount must be nonzero. + ZeroEditedAmount, + /// A widened calculation produced a result outside the chain's `u128` amount range. + ArithmeticOverflow { operation: &'static str }, + /// Explicit opening amounts do not encode the requested Q64.64 spot price exactly. + SpotPriceMismatch { desired: u128, actual: u128 }, + /// A pool or quoted pool update has a zero directional reserve. + ZeroDirectionalReserve, + /// The supplied quote moves the directional spot price opposite to its swap direction. + SpotMovedAgainstSwap, + /// Canonical program quote logic rejected the prepared amounts. + Quote { + code: &'static str, + message: &'static str, + }, +} + +impl IntentError { + /// Stable machine-readable error code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::IdenticalTokenDefinitions => "identical_token_definitions", + Self::ZeroDesiredPrice => "zero_desired_price", + Self::ZeroEditedAmount => "zero_edited_amount", + Self::ArithmeticOverflow { .. } => "intent_arithmetic_overflow", + Self::SpotPriceMismatch { .. } => "spot_price_mismatch", + Self::ZeroDirectionalReserve => "zero_directional_reserve", + Self::SpotMovedAgainstSwap => "spot_moved_against_swap", + Self::Quote { code, .. } => code, + } + } +} + +impl fmt::Display for IntentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IdenticalTokenDefinitions => { + formatter.write_str("pool token definitions must be distinct") + } + Self::ZeroDesiredPrice => formatter.write_str("desired Q64.64 price must be nonzero"), + Self::ZeroEditedAmount => formatter.write_str("edited token amount must be nonzero"), + Self::ArithmeticOverflow { operation } => { + write!(formatter, "{operation} exceeds the u128 amount range") + } + Self::SpotPriceMismatch { desired, actual } => write!( + formatter, + "opening amounts encode Q64.64 price {actual}, not requested price {desired}" + ), + Self::ZeroDirectionalReserve => { + formatter.write_str("directional pool reserves must be nonzero") + } + Self::SpotMovedAgainstSwap => { + formatter.write_str("quoted spot price moved opposite to the swap direction") + } + Self::Quote { message, .. } => formatter.write_str(message), + } + } +} + +impl Error for IntentError {} + +impl From for IntentError { + fn from(error: program_quote::QuoteError) -> Self { + Self::Quote { + code: error.code(), + message: error.message(), + } + } +} + +/// Executable opening amounts plus their canonical program quote. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct PreparedOpeningPair { + /// Requested Q64.64 target used to pair or minimize the amounts. + pub desired_price_q64_64: u128, + /// Exact Q64.64 price encoded by the returned integer amounts. + pub actual_price_q64_64: u128, + /// Stored token-A amount for `NewDefinition`. + pub token_a_amount: u128, + /// Stored token-B amount for `NewDefinition`. + pub token_b_amount: u128, + /// Fee tier passed to canonical pool-creation quote logic. + pub fee_bps: u128, + /// Canonical pool-creation result for the returned amounts. + pub quote: CreatePoolQuote, +} + +/// Caller-facing source for an opening-liquidity pair. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum OpeningLiquidityIntent { + /// Find the smallest executable pair at the requested price. + Minimum, + /// Pair an amount edited for the caller's first token. + FirstAmount(u128), + /// Pair an amount edited for the caller's second token. + SecondAmount(u128), + /// Validate two explicit amounts in caller first/second order. + Explicit { + first_amount: u128, + second_amount: u128, + }, +} + +/// Executable opening amounts in both caller and canonical stored order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedCallerOpeningPair { + caller_order: PairOrder, + first_amount: u128, + second_amount: u128, + stored: PreparedOpeningPair, +} + +impl PreparedCallerOpeningPair { + /// Caller first/second order relative to canonical stored A/B order. + #[must_use] + pub const fn caller_order(&self) -> PairOrder { + self.caller_order + } + + #[must_use] + pub const fn first_amount(&self) -> u128 { + self.first_amount + } + + #[must_use] + pub const fn second_amount(&self) -> u128 { + self.second_amount + } + + /// Canonical token-A/token-B result ready for pool-creation validation and planning. + #[must_use] + pub const fn stored(&self) -> &PreparedOpeningPair { + &self.stored + } +} + +/// Prepares an opening pair without requiring a caller to reproduce canonical token ordering. +pub fn prepare_caller_opening_pair( + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + desired_price_q64_64: u128, + fee_bps: u128, + intent: OpeningLiquidityIntent, +) -> Result { + let Some((stored_a_id, _)) = + canonical_token_pair(first_token_definition_id, second_token_definition_id) + else { + return Err(IntentError::IdenticalTokenDefinitions); + }; + let caller_order = if first_token_definition_id == stored_a_id { + PairOrder::Stored + } else { + PairOrder::Reversed + }; + let stored = match intent { + OpeningLiquidityIntent::Minimum => { + prepare_minimum_opening_pair(desired_price_q64_64, fee_bps)? + } + OpeningLiquidityIntent::FirstAmount(first_amount) => match caller_order { + PairOrder::Stored => { + prepare_opening_from_token_a(first_amount, desired_price_q64_64, fee_bps)? + } + PairOrder::Reversed => { + prepare_opening_from_token_b(first_amount, desired_price_q64_64, fee_bps)? + } + }, + OpeningLiquidityIntent::SecondAmount(second_amount) => match caller_order { + PairOrder::Stored => { + prepare_opening_from_token_b(second_amount, desired_price_q64_64, fee_bps)? + } + PairOrder::Reversed => { + prepare_opening_from_token_a(second_amount, desired_price_q64_64, fee_bps)? + } + }, + OpeningLiquidityIntent::Explicit { + first_amount, + second_amount, + } => { + let (token_a_amount, token_b_amount) = + caller_order.amounts_to_stored(first_amount, second_amount); + validate_explicit_opening_pair( + token_a_amount, + token_b_amount, + desired_price_q64_64, + fee_bps, + )? + } + }; + let (first_amount, second_amount) = + caller_order.amounts_from_stored(stored.token_a_amount, stored.token_b_amount); + Ok(PreparedCallerOpeningPair { + caller_order, + first_amount, + second_amount, + stored, + }) +} + +/// Returns the token-B amount paired with an edited token-A amount. +/// +/// The result is `ceil(token_a_amount * desired_price / 2^64)`, computed with the same widened +/// integer helper used by AMM code. Callers can inspect the actual representable price returned by +/// [`prepare_opening_from_token_a`] when integer rounding cannot reproduce the target exactly. +pub fn paired_amount_from_token_a( + token_a_amount: u128, + desired_price_q64_64: u128, +) -> Result { + validate_pairing_inputs(token_a_amount, desired_price_q64_64)?; + checked_mul_div_ceil(token_a_amount, desired_price_q64_64, Q64_64_ONE).ok_or( + IntentError::ArithmeticOverflow { + operation: "token-A to token-B pairing", + }, + ) +} + +/// Returns the token-A amount paired with an edited token-B amount. +/// +/// The result is `ceil(token_b_amount * 2^64 / desired_price)`, using checked widened arithmetic. +pub fn paired_amount_from_token_b( + token_b_amount: u128, + desired_price_q64_64: u128, +) -> Result { + validate_pairing_inputs(token_b_amount, desired_price_q64_64)?; + checked_mul_div_ceil(token_b_amount, Q64_64_ONE, desired_price_q64_64).ok_or( + IntentError::ArithmeticOverflow { + operation: "token-B to token-A pairing", + }, + ) +} + +/// Finds the smallest executable opening pair on the price's base side. +/// +/// For prices at least one, token A is minimized. For prices below one, token B is minimized. The +/// opposite amount is conservatively rounded up. The returned values are always passed through +/// [`amm_program::quote::create_pool`] before success is returned. +pub fn prepare_minimum_opening_pair( + desired_price_q64_64: u128, + fee_bps: u128, +) -> Result { + if desired_price_q64_64 == 0 { + return Err(IntentError::ZeroDesiredPrice); + } + let upper = MINIMUM_LIQUIDITY + .checked_add(1) + .ok_or(IntentError::ArithmeticOverflow { + operation: "minimum opening-liquidity bound", + })?; + + let (token_a_amount, token_b_amount) = if desired_price_q64_64 >= Q64_64_ONE { + let token_a_amount = first_executable(upper, |candidate_a| { + let candidate_b = paired_amount_from_token_a(candidate_a, desired_price_q64_64)?; + Ok(isqrt_product(candidate_a, candidate_b) > MINIMUM_LIQUIDITY) + })?; + ( + token_a_amount, + paired_amount_from_token_a(token_a_amount, desired_price_q64_64)?, + ) + } else { + let token_b_amount = first_executable(upper, |candidate_b| { + let candidate_a = paired_amount_from_token_b(candidate_b, desired_price_q64_64)?; + Ok(isqrt_product(candidate_a, candidate_b) > MINIMUM_LIQUIDITY) + })?; + ( + paired_amount_from_token_b(token_b_amount, desired_price_q64_64)?, + token_b_amount, + ) + }; + + prepare_opening_pair( + desired_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + ) +} + +/// Pairs an edited token-A amount and validates the resulting pool creation through program logic. +pub fn prepare_opening_from_token_a( + token_a_amount: u128, + desired_price_q64_64: u128, + fee_bps: u128, +) -> Result { + let token_b_amount = paired_amount_from_token_a(token_a_amount, desired_price_q64_64)?; + prepare_opening_pair( + desired_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + ) +} + +/// Pairs an edited token-B amount and validates the resulting pool creation through program logic. +pub fn prepare_opening_from_token_b( + token_b_amount: u128, + desired_price_q64_64: u128, + fee_bps: u128, +) -> Result { + let token_a_amount = paired_amount_from_token_b(token_b_amount, desired_price_q64_64)?; + prepare_opening_pair( + desired_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + ) +} + +/// Validates explicit opening amounts and requires their Q64.64 spot price to match exactly. +pub fn validate_explicit_opening_pair( + token_a_amount: u128, + token_b_amount: u128, + desired_price_q64_64: u128, + fee_bps: u128, +) -> Result { + let prepared = prepare_opening_pair( + desired_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + )?; + if prepared.actual_price_q64_64 != desired_price_q64_64 { + return Err(IntentError::SpotPriceMismatch { + desired: desired_price_q64_64, + actual: prepared.actual_price_q64_64, + }); + } + Ok(prepared) +} + +/// Converts caller first/second amounts to the pool's stored A/B order. +#[must_use] +pub const fn caller_amounts_to_stored(order: PairOrder, first: u128, second: u128) -> (u128, u128) { + order.amounts_to_stored(first, second) +} + +/// Converts stored pool A/B amounts back to caller first/second order. +#[must_use] +pub const fn stored_amounts_to_caller( + order: PairOrder, + amount_a: u128, + amount_b: u128, +) -> (u128, u128) { + order.amounts_from_stored(amount_a, amount_b) +} + +/// Returns nonnegative directional pool spot movement in basis points for a canonical swap quote. +/// +/// This computes, with one final floor operation: +/// +/// `10_000 * (post_price - pre_price) / pre_price` +/// +/// Reserves are oriented as input/output according to the quote direction. Intermediate products +/// use a widened integer so values remain exact even when reserve products exceed `u128`. +pub fn pool_spot_change_bps( + before: &PoolDefinition, + quote: &SwapQuote, +) -> Result { + let (pre_in, pre_out, post_in, post_out) = match quote.direction { + SwapDirection::AToB => ( + before.reserve_a, + before.reserve_b, + quote.pool.reserve_a, + quote.pool.reserve_b, + ), + SwapDirection::BToA => ( + before.reserve_b, + before.reserve_a, + quote.pool.reserve_b, + quote.pool.reserve_a, + ), + }; + if [pre_in, pre_out, post_in, post_out] + .into_iter() + .any(|reserve| reserve == 0) + { + return Err(IntentError::ZeroDirectionalReserve); + } + + let post_price_numerator = U512::from(post_in).checked_mul(U512::from(pre_out)).ok_or( + IntentError::ArithmeticOverflow { + operation: "directional post-price numerator", + }, + )?; + let relative_change_denominator = U512::from(post_out).checked_mul(U512::from(pre_in)).ok_or( + IntentError::ArithmeticOverflow { + operation: "directional relative-change denominator", + }, + )?; + let increase = post_price_numerator + .checked_sub(relative_change_denominator) + .ok_or(IntentError::SpotMovedAgainstSwap)?; + let numerator = + increase + .checked_mul(U512::from(10_000_u128)) + .ok_or(IntentError::ArithmeticOverflow { + operation: "directional basis-point numerator", + })?; + let change = numerator.checked_div(relative_change_denominator).ok_or( + IntentError::ArithmeticOverflow { + operation: "directional basis-point division", + }, + )?; + u128::try_from(change).map_err(|_| IntentError::ArithmeticOverflow { + operation: "directional basis-point result", + }) +} + +fn validate_pairing_inputs( + edited_amount: u128, + desired_price_q64_64: u128, +) -> Result<(), IntentError> { + if edited_amount == 0 { + return Err(IntentError::ZeroEditedAmount); + } + if desired_price_q64_64 == 0 { + return Err(IntentError::ZeroDesiredPrice); + } + Ok(()) +} + +fn prepare_opening_pair( + desired_price_q64_64: u128, + token_a_amount: u128, + token_b_amount: u128, + fee_bps: u128, +) -> Result { + if desired_price_q64_64 == 0 { + return Err(IntentError::ZeroDesiredPrice); + } + let quote = program_quote::create_pool(token_a_amount, token_b_amount, fee_bps)?; + let actual_price_q64_64 = spot_price_q64_64(token_a_amount, token_b_amount); + Ok(PreparedOpeningPair { + desired_price_q64_64, + actual_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + quote, + }) +} + +fn first_executable( + upper: u128, + mut executable: impl FnMut(u128) -> Result, +) -> Result { + let mut lower = 1_u128; + let mut upper = upper; + while lower < upper { + let distance = upper + .checked_sub(lower) + .ok_or(IntentError::ArithmeticOverflow { + operation: "opening-pair search range", + })?; + let half = distance + .checked_div(2) + .ok_or(IntentError::ArithmeticOverflow { + operation: "opening-pair search division", + })?; + let midpoint = lower + .checked_add(half) + .ok_or(IntentError::ArithmeticOverflow { + operation: "opening-pair search midpoint", + })?; + if executable(midpoint)? { + upper = midpoint; + } else { + lower = midpoint + .checked_add(1) + .ok_or(IntentError::ArithmeticOverflow { + operation: "opening-pair search increment", + })?; + } + } + Ok(lower) +} diff --git a/programs/amm/client/src/lib.rs b/programs/amm/client/src/lib.rs index e384f38e..d8b9724e 100644 --- a/programs/amm/client/src/lib.rs +++ b/programs/amm/client/src/lib.rs @@ -1,14 +1,29 @@ //! Stateless AMM quoting and transaction planning for host consumers. +pub mod discovery; pub mod error; mod ffi; +pub mod intent; pub mod plan; pub mod quote; pub mod slippage; +pub mod transaction; pub mod wire; +pub use discovery::{ + canonical_pair, derive_config_id, derive_pair_read_manifest, inspect_config, inspect_pair, + ActivePairInspection, CanonicalPair, MissingPairInspection, MissingVaultState, PairInspection, + PairReadManifest, PairReadSnapshots, TokenReadManifest, ValidatedClockSnapshot, +}; pub use error::ClientError; pub use ffi::{amm_client_free, amm_client_plan, amm_client_quote}; +pub use intent::{ + caller_amounts_to_stored, paired_amount_from_token_a, paired_amount_from_token_b, + pool_spot_change_bps, prepare_caller_opening_pair, prepare_minimum_opening_pair, + prepare_opening_from_token_a, prepare_opening_from_token_b, stored_amounts_to_caller, + validate_explicit_opening_pair, IntentError, OpeningLiquidityIntent, PreparedCallerOpeningPair, + PreparedOpeningPair, Q64_64_ONE, +}; pub use plan::{ encode_instruction, plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input, @@ -24,3 +39,11 @@ pub use slippage::{ PreparedAddLiquidity, PreparedCreatePool, PreparedRemoveLiquidity, PreparedSwapExactInput, PreparedSwapExactOutput, SlippageTolerance, SLIPPAGE_BPS_DENOMINATOR, }; +pub use transaction::{ + ensure_quote_unchanged, prepare_add_liquidity_transaction, prepare_create_pool_transaction, + prepare_remove_liquidity_transaction, prepare_swap_exact_input_transaction, + prepare_swap_exact_output_transaction, AddLiquidityTransactionInput, CallerAmounts, + CreatePoolTransactionInput, FundingRequirement, PoolAccountSnapshots, PreparedTransaction, + QuoteCommitment, RemoveLiquidityTransactionInput, SwapExactInputTransactionInput, + SwapExactOutputTransactionInput, TransactionError, TransactionOperation, WalletPrerequisites, +}; diff --git a/programs/amm/client/src/plan.rs b/programs/amm/client/src/plan.rs index 29abdee3..f75fd769 100644 --- a/programs/amm/client/src/plan.rs +++ b/programs/amm/client/src/plan.rs @@ -247,6 +247,27 @@ impl TransactionPlan { .collect() } + /// Writable account IDs in first-occurrence instruction order. + #[must_use] + pub fn writable_account_ids(&self) -> Vec { + self.accounts + .iter() + .filter(|account| account.writable()) + .map(PlannedAccount::id) + .fold(Vec::new(), |mut ids, id| { + if !ids.contains(&id) { + ids.push(id); + } + ids + }) + } + + /// Account IDs whose state may change if the instruction succeeds. + #[must_use] + pub fn affected_account_ids(&self) -> Vec { + self.writable_account_ids() + } + /// Guest instruction name, kept exhaustive over the canonical enum. #[must_use] pub const fn instruction_name(&self) -> &'static str { diff --git a/programs/amm/client/src/slippage.rs b/programs/amm/client/src/slippage.rs index 6baa19ca..e934be5d 100644 --- a/programs/amm/client/src/slippage.rs +++ b/programs/amm/client/src/slippage.rs @@ -158,6 +158,10 @@ pub fn prepare_create_pool( } /// Quotes add liquidity and derives its minimum-LP guard. +/// +/// The instruction maxima remain the caller's original caps. Reusing the rounded actual deposits +/// as new maxima is not behavior-preserving for non-divisible reserve ratios: the program rounds +/// the proportional amounts again and can produce a different quote. pub fn prepare_add_liquidity( snapshot: &ValidatedPoolSnapshot, max_amount_a: u128, @@ -166,20 +170,14 @@ pub fn prepare_add_liquidity( ) -> Result { let preview = client_quote::preview_add_liquidity(snapshot, max_amount_a, max_amount_b)?; let min_amount_liquidity = minimum_guard_amount(preview.liquidity_to_mint, tolerance)?; - let max_amount_to_add_token_a = preview.actual_amount_a; - let max_amount_to_add_token_b = preview.actual_amount_b; - let quote = client_quote::add_liquidity( - snapshot, - max_amount_to_add_token_a, - max_amount_to_add_token_b, - min_amount_liquidity, - )?; + let quote = + client_quote::add_liquidity(snapshot, max_amount_a, max_amount_b, min_amount_liquidity)?; Ok(PreparedAddLiquidity { quote, min_amount_liquidity, - max_amount_to_add_token_a, - max_amount_to_add_token_b, + max_amount_to_add_token_a: max_amount_a, + max_amount_to_add_token_b: max_amount_b, }) } @@ -251,6 +249,13 @@ pub fn prepare_swap_exact_output( exact_amount_out, )?; let max_amount_in = maximum_guard_amount(preview.amount_in, tolerance)?; + if user_input.balance() < max_amount_in { + return Err(ClientError::InsufficientBalance { + account: "user input holding", + available: user_input.balance(), + required: max_amount_in, + }); + } let quote = client_quote::swap_exact_output( snapshot, user_input, diff --git a/programs/amm/client/src/transaction.rs b/programs/amm/client/src/transaction.rs new file mode 100644 index 00000000..2d55a77c --- /dev/null +++ b/programs/amm/client/src/transaction.rs @@ -0,0 +1,1196 @@ +//! Snapshot-to-transaction facade for wallet and API consumers. +//! +//! Each operation validates immutable account snapshots, delegates economic calculations to the +//! program-owned quote API, applies the shared slippage policy, and emits the canonical planner +//! output. The facade is deterministic and performs no RPC, signing, submission, clock lookup, or +//! runtime program-identity/version check. + +use std::{error::Error, fmt}; + +use amm_program::quote::{ + AddLiquidityQuote, CreatePoolQuote, PairOrder, RemoveLiquidityQuote, SwapQuote, +}; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, + Commitment, +}; +use risc0_zkvm::sha::{Impl, Sha256 as _}; +use serde::Serialize; +use token_core::TokenHolding; + +use crate::{ + discovery::{ + inspect_config, inspect_pair, ActivePairInspection, PairInspection, PairReadSnapshots, + }, + intent::{pool_spot_change_bps, IntentError}, + plan::{ + plan_add_liquidity, plan_create_pool, plan_remove_liquidity, plan_swap_exact_input, + plan_swap_exact_output, AddLiquidityPlanInput, CreatePoolPlanInput, PoolContext, + RemoveLiquidityPlanInput, SwapExactInputPlanInput, SwapExactOutputPlanInput, + TransactionPlan, + }, + quote::{ + AccountSnapshot, ValidatedFungibleDefinition, ValidatedFungibleHolding, + ValidatedPoolSnapshot, + }, + slippage::{ + prepare_add_liquidity, prepare_create_pool, prepare_remove_liquidity, + prepare_swap_exact_input, prepare_swap_exact_output, SlippageTolerance, + }, + AmmContext, ClientError, +}; + +const COMMITMENT_DOMAIN: &str = "lez.amm.client.prepared-transaction.v1"; + +/// One AMM operation represented by a prepared transaction. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[non_exhaustive] +pub enum TransactionOperation { + CreatePool, + AddLiquidity, + RemoveLiquidity, + SwapExactInput, + SwapExactOutput, +} + +/// Exact operation amounts expressed in caller first/second order. +/// +/// For pool creation and liquidity operations, `first` and `second` correspond to the supplied +/// token-definition IDs. For swaps, `first` is input and `second` is output. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct CallerAmounts { + first: u128, + second: u128, +} + +impl CallerAmounts { + #[must_use] + pub const fn new(first: u128, second: u128) -> Self { + Self { first, second } + } + + #[must_use] + pub const fn first(self) -> u128 { + self.first + } + + #[must_use] + pub const fn second(self) -> u128 { + self.second + } +} + +/// One selected wallet holding and the spend capacity required by the instruction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FundingRequirement { + holding_account_id: AccountId, + token_definition_id: AccountId, + available: u128, + required: u128, +} + +impl FundingRequirement { + #[must_use] + pub const fn holding_account_id(&self) -> AccountId { + self.holding_account_id + } + + #[must_use] + pub const fn token_definition_id(&self) -> AccountId { + self.token_definition_id + } + + #[must_use] + pub const fn available(&self) -> u128 { + self.available + } + + #[must_use] + pub const fn required(&self) -> u128 { + self.required + } +} + +/// Wallet-owned prerequisites extracted from the exact plan and selected snapshots. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WalletPrerequisites { + signer_account_ids: Vec, + fresh_account_ids: Vec, + funding: Vec, +} + +impl WalletPrerequisites { + /// Accounts that must be authorized, in instruction-account order. + #[must_use] + pub fn signer_account_ids(&self) -> &[AccountId] { + &self.signer_account_ids + } + + /// Selected destination accounts whose supplied snapshot was exactly `Account::default()`. + #[must_use] + pub fn fresh_account_ids(&self) -> &[AccountId] { + &self.fresh_account_ids + } + + /// Funding requirements in caller token order. + #[must_use] + pub fn funding(&self) -> &[FundingRequirement] { + &self.funding + } +} + +/// SHA-256 commitment to the typed request, exact plan, and role-tagged account snapshots. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct QuoteCommitment([u8; 32]); + +impl QuoteCommitment { + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + #[must_use] + pub const fn into_bytes(self) -> [u8; 32] { + self.0 + } + + /// Returns `quote_changed` when a refreshed preparation no longer matches this commitment. + pub fn ensure_unchanged(self, actual: Self) -> Result<(), TransactionError> { + ensure_quote_unchanged(self, actual) + } +} + +/// Compares a previously presented quote commitment with a freshly prepared one. +pub fn ensure_quote_unchanged( + expected: QuoteCommitment, + actual: QuoteCommitment, +) -> Result<(), TransactionError> { + if expected.0 == actual.0 { + Ok(()) + } else { + Err(TransactionError::QuoteChanged { expected, actual }) + } +} + +/// Canonical quote paired with the exact transaction that consumes it. +pub struct PreparedTransaction { + operation: TransactionOperation, + quote: Q, + plan: TransactionPlan, + quote_commitment: QuoteCommitment, + affected_account_ids: Vec, + wallet_prerequisites: WalletPrerequisites, + caller_amounts: CallerAmounts, + deadline: u64, + pool_spot_change_bps: Option, +} + +impl PreparedTransaction { + #[must_use] + pub const fn operation(&self) -> TransactionOperation { + self.operation + } + + #[must_use] + pub const fn quote(&self) -> &Q { + &self.quote + } + + #[must_use] + pub const fn plan(&self) -> &TransactionPlan { + &self.plan + } + + #[must_use] + pub const fn quote_commitment(&self) -> QuoteCommitment { + self.quote_commitment + } + + /// Writable account IDs in first-occurrence instruction order. + #[must_use] + pub fn affected_account_ids(&self) -> &[AccountId] { + &self.affected_account_ids + } + + #[must_use] + pub const fn wallet_prerequisites(&self) -> &WalletPrerequisites { + &self.wallet_prerequisites + } + + #[must_use] + pub const fn caller_amounts(&self) -> CallerAmounts { + self.caller_amounts + } + + #[must_use] + pub const fn deadline(&self) -> u64 { + self.deadline + } + + /// Directional pre/post pool spot movement for swaps; absent for non-swap operations. + #[must_use] + pub const fn pool_spot_change_bps(&self) -> Option { + self.pool_spot_change_bps + } + + #[must_use] + pub fn into_quote_and_plan(self) -> (Q, TransactionPlan) { + (self.quote, self.plan) + } +} + +/// Failure while validating snapshots or materializing a prepared transaction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum TransactionError { + Client(ClientError), + Intent(IntentError), + FeeMismatch { + expected: u128, + actual: u128, + }, + QuoteChanged { + expected: QuoteCommitment, + actual: QuoteCommitment, + }, + DuplicateAccountId { + account_id: AccountId, + }, + InstructionEncoding, + CommitmentEncoding, +} + +impl TransactionError { + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::Client(error) => error.code(), + Self::Intent(error) => error.code(), + Self::FeeMismatch { .. } => "fee_mismatch", + Self::QuoteChanged { .. } => "quote_changed", + Self::DuplicateAccountId { .. } => "duplicate_account_id", + Self::InstructionEncoding => "instruction_encoding_failed", + Self::CommitmentEncoding => "quote_commitment_encoding_failed", + } + } +} + +impl fmt::Display for TransactionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Client(error) => error.fmt(formatter), + Self::Intent(error) => error.fmt(formatter), + Self::FeeMismatch { expected, actual } => write!( + formatter, + "expected pool fee {expected} bps, snapshot contains {actual} bps" + ), + Self::QuoteChanged { .. } => { + formatter.write_str("prepared quote changed after snapshot refresh") + } + Self::DuplicateAccountId { .. } => { + formatter.write_str("transaction plan contains a duplicate account ID") + } + Self::InstructionEncoding => { + formatter.write_str("AMM instruction serialization failed") + } + Self::CommitmentEncoding => { + formatter.write_str("prepared-transaction commitment serialization failed") + } + } + } +} + +impl Error for TransactionError {} + +impl From for TransactionError { + fn from(error: ClientError) -> Self { + Self::Client(error) + } +} + +impl From for TransactionError { + fn from(error: IntentError) -> Self { + Self::Intent(error) + } +} + +/// Raw fetched accounts required to validate an initialized pool. +#[derive(Clone, Copy)] +pub struct PoolAccountSnapshots<'a> { + pub config: &'a AccountSnapshot, + pub pair: PairReadSnapshots<'a>, +} + +impl PoolAccountSnapshots<'_> { + /// Validates config, complete pair lifecycle, current tick, and clock snapshots. + pub fn validate( + self, + amm_program_id: ProgramId, + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + ) -> Result<(AmmContext, Box), ClientError> { + let context = inspect_config(amm_program_id, self.config)?; + match inspect_pair( + &context, + first_token_definition_id, + second_token_definition_id, + self.pair, + )? { + PairInspection::Active(active) => Ok((context, active)), + PairInspection::Missing(_) => Err(ClientError::InvalidAccountData { + account: "AMM pool", + expected: "initialized pool lifecycle", + }), + } + } +} + +/// Caller-order pool-creation request over raw account snapshots. +#[derive(Clone, Copy)] +pub struct CreatePoolTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub config: &'a AccountSnapshot, + pub pair: PairReadSnapshots<'a>, + pub first_token_definition_id: AccountId, + pub second_token_definition_id: AccountId, + pub first_token_holding: &'a AccountSnapshot, + pub second_token_holding: &'a AccountSnapshot, + pub liquidity_holding: &'a AccountSnapshot, + pub first_amount: u128, + pub second_amount: u128, + pub fee_bps: u128, + pub deadline: u64, +} + +/// Caller-order add-liquidity request over a complete pool snapshot. +#[derive(Clone, Copy)] +pub struct AddLiquidityTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub pool_accounts: PoolAccountSnapshots<'a>, + pub first_token_definition_id: AccountId, + pub second_token_definition_id: AccountId, + pub first_token_holding: &'a AccountSnapshot, + pub second_token_holding: &'a AccountSnapshot, + pub liquidity_holding: &'a AccountSnapshot, + pub max_first_amount: u128, + pub max_second_amount: u128, + pub slippage: SlippageTolerance, + pub expected_fee_bps: Option, + pub deadline: u64, +} + +/// Caller-order remove-liquidity request over a complete pool snapshot. +#[derive(Clone, Copy)] +pub struct RemoveLiquidityTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub pool_accounts: PoolAccountSnapshots<'a>, + pub first_token_definition_id: AccountId, + pub second_token_definition_id: AccountId, + pub first_token_holding: &'a AccountSnapshot, + pub second_token_holding: &'a AccountSnapshot, + pub liquidity_holding: &'a AccountSnapshot, + pub remove_liquidity_amount: u128, + pub slippage: SlippageTolerance, + pub expected_fee_bps: Option, + pub deadline: u64, +} + +/// Exact-input swap request over a complete pool snapshot. +#[derive(Clone, Copy)] +pub struct SwapExactInputTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub pool_accounts: PoolAccountSnapshots<'a>, + pub input_token_definition_id: AccountId, + pub output_token_definition_id: AccountId, + pub input_holding: &'a AccountSnapshot, + pub output_holding: &'a AccountSnapshot, + pub amount_in: u128, + pub slippage: SlippageTolerance, + pub expected_fee_bps: Option, + pub deadline: u64, +} + +/// Exact-output swap request over a complete pool snapshot. +#[derive(Clone, Copy)] +pub struct SwapExactOutputTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub pool_accounts: PoolAccountSnapshots<'a>, + pub input_token_definition_id: AccountId, + pub output_token_definition_id: AccountId, + pub input_holding: &'a AccountSnapshot, + pub output_holding: &'a AccountSnapshot, + pub exact_amount_out: u128, + pub slippage: SlippageTolerance, + pub expected_fee_bps: Option, + pub deadline: u64, +} + +/// Validates funding, quotes creation, and emits one exact `NewDefinition` plan. +pub fn prepare_create_pool_transaction( + input: CreatePoolTransactionInput<'_>, +) -> Result, TransactionError> { + let context = inspect_config(input.amm_program_id, input.config)?; + let missing = match inspect_pair( + &context, + input.first_token_definition_id, + input.second_token_definition_id, + input.pair, + )? { + PairInspection::Missing(missing) => missing, + PairInspection::Active(_) => { + return Err(ClientError::InvalidAccountData { + account: "AMM pool", + expected: "uninitialized pool lifecycle", + } + .into()) + } + }; + let first_definition = missing.first_token_definition(); + let second_definition = missing.second_token_definition(); + let first_holding = + ValidatedFungibleHolding::new(&context, input.first_token_holding, first_definition)?; + let second_holding = + ValidatedFungibleHolding::new(&context, input.second_token_holding, second_definition)?; + + let canonical = missing.manifest().canonical_pair(); + let stored_a_id = canonical.token_a_id(); + let stored_b_id = canonical.token_b_id(); + let order = if first_definition.account_id() == stored_a_id { + PairOrder::Stored + } else { + PairOrder::Reversed + }; + let (stored_a_definition, stored_b_definition, stored_a_holding, stored_b_holding) = match order + { + PairOrder::Stored => ( + &first_definition, + &second_definition, + &first_holding, + &second_holding, + ), + PairOrder::Reversed => ( + &second_definition, + &first_definition, + &second_holding, + &first_holding, + ), + }; + let (stored_a_amount, stored_b_amount) = + order.amounts_to_stored(input.first_amount, input.second_amount); + let prepared = prepare_create_pool( + &context, + stored_a_definition, + stored_b_definition, + stored_a_amount, + stored_b_amount, + input.fee_bps, + )?; + ensure_funded(&first_holding, input.first_amount, "first token holding")?; + ensure_funded(&second_holding, input.second_amount, "second token holding")?; + + let fresh_liquidity = validate_holding_destination( + &context, + input.liquidity_holding, + missing.manifest().liquidity_definition_id(), + "liquidity holding", + )?; + let plan = plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: stored_a_id, + token_b_definition_id: stored_b_id, + user_holding_a: stored_a_holding.account_id(), + user_holding_b: stored_b_holding.account_id(), + user_holding_lp: input.liquidity_holding.account_id(), + token_a_amount: prepared.token_a_amount, + token_b_amount: prepared.token_b_amount, + fees: prepared.fees, + deadline: input.deadline, + })?; + let sources = pair_sources( + input.config, + input.pair, + input.first_token_holding, + input.second_token_holding, + Some(input.liquidity_holding), + ); + let funding = vec![ + funding(&first_holding, input.first_amount), + funding(&second_holding, input.second_amount), + ]; + let fresh = fresh_liquidity + .then_some(input.liquidity_holding.account_id()) + .into_iter() + .collect(); + + PreparedTransaction::new( + TransactionOperation::CreatePool, + TransactionIntent::CreatePool { + first_token_definition_id: input.first_token_definition_id, + second_token_definition_id: input.second_token_definition_id, + first_amount: input.first_amount, + second_amount: input.second_amount, + fee_bps: input.fee_bps, + }, + prepared.quote, + plan, + sources, + fresh, + funding, + CallerAmounts::new(input.first_amount, input.second_amount), + input.deadline, + None, + ) +} + +/// Validates funding, quotes a proportional deposit, and emits one exact add plan. +pub fn prepare_add_liquidity_transaction( + input: AddLiquidityTransactionInput<'_>, +) -> Result, TransactionError> { + let (context, active) = input.pool_accounts.validate( + input.amm_program_id, + input.first_token_definition_id, + input.second_token_definition_id, + )?; + let snapshot = active.pool(); + let order = active.caller_order(); + validate_expected_fee(snapshot, input.expected_fee_bps)?; + let (first_definition, second_definition) = caller_definitions(snapshot, order); + let first_holding = + ValidatedFungibleHolding::new(&context, input.first_token_holding, first_definition)?; + let second_holding = + ValidatedFungibleHolding::new(&context, input.second_token_holding, second_definition)?; + let (stored_max_a, stored_max_b) = + order.amounts_to_stored(input.max_first_amount, input.max_second_amount); + let prepared = prepare_add_liquidity(snapshot, stored_max_a, stored_max_b, input.slippage)?; + let (quoted_first, quoted_second) = order.amounts_from_stored( + prepared.quote.actual_amount_a, + prepared.quote.actual_amount_b, + ); + ensure_funded( + &first_holding, + input.max_first_amount, + "first token holding", + )?; + ensure_funded( + &second_holding, + input.max_second_amount, + "second token holding", + )?; + let fresh_liquidity = validate_holding_destination( + &context, + input.liquidity_holding, + snapshot.liquidity_definition().account_id(), + "liquidity holding", + )?; + let (stored_holding_a, stored_holding_b) = + stored_holdings(order, &first_holding, &second_holding); + let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?; + let plan = plan_add_liquidity(AddLiquidityPlanInput { + context: &context, + pool, + user_holding_a: stored_holding_a.account_id(), + user_holding_b: stored_holding_b.account_id(), + user_holding_lp: input.liquidity_holding.account_id(), + min_amount_liquidity: prepared.min_amount_liquidity, + max_amount_to_add_token_a: prepared.max_amount_to_add_token_a, + max_amount_to_add_token_b: prepared.max_amount_to_add_token_b, + deadline: input.deadline, + }); + let sources = pool_sources( + input.pool_accounts, + input.first_token_holding, + input.second_token_holding, + Some(input.liquidity_holding), + ); + let funding = vec![ + funding(&first_holding, input.max_first_amount), + funding(&second_holding, input.max_second_amount), + ]; + let fresh = fresh_liquidity + .then_some(input.liquidity_holding.account_id()) + .into_iter() + .collect(); + + PreparedTransaction::new( + TransactionOperation::AddLiquidity, + TransactionIntent::AddLiquidity { + first_token_definition_id: input.first_token_definition_id, + second_token_definition_id: input.second_token_definition_id, + max_first_amount: input.max_first_amount, + max_second_amount: input.max_second_amount, + slippage_bps: input.slippage.bps(), + expected_fee_bps: input.expected_fee_bps, + }, + prepared.quote, + plan, + sources, + fresh, + funding, + CallerAmounts::new(quoted_first, quoted_second), + input.deadline, + None, + ) +} + +/// Quotes an LP burn and emits one exact remove plan. +pub fn prepare_remove_liquidity_transaction( + input: RemoveLiquidityTransactionInput<'_>, +) -> Result, TransactionError> { + let (context, active) = input.pool_accounts.validate( + input.amm_program_id, + input.first_token_definition_id, + input.second_token_definition_id, + )?; + let snapshot = active.pool(); + let order = active.caller_order(); + validate_expected_fee(snapshot, input.expected_fee_bps)?; + let (first_definition, second_definition) = caller_definitions(snapshot, order); + let first_fresh = validate_holding_destination( + &context, + input.first_token_holding, + first_definition.account_id(), + "first token holding", + )?; + let second_fresh = validate_holding_destination( + &context, + input.second_token_holding, + second_definition.account_id(), + "second token holding", + )?; + let liquidity_holding = ValidatedFungibleHolding::new( + &context, + input.liquidity_holding, + snapshot.liquidity_definition(), + )?; + let prepared = prepare_remove_liquidity( + snapshot, + &liquidity_holding, + input.remove_liquidity_amount, + input.slippage, + )?; + let caller_amounts = order.amounts_from_stored( + prepared.quote.withdraw_amount_a, + prepared.quote.withdraw_amount_b, + ); + let (stored_holding_a, stored_holding_b) = order_pair( + order, + input.first_token_holding.account_id(), + input.second_token_holding.account_id(), + ); + let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?; + let plan = plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &context, + pool, + user_holding_a: stored_holding_a, + user_holding_b: stored_holding_b, + user_holding_lp: liquidity_holding.account_id(), + remove_liquidity_amount: prepared.remove_liquidity_amount, + min_amount_to_remove_token_a: prepared.min_amount_to_remove_token_a, + min_amount_to_remove_token_b: prepared.min_amount_to_remove_token_b, + deadline: input.deadline, + }); + let sources = pool_sources( + input.pool_accounts, + input.first_token_holding, + input.second_token_holding, + Some(input.liquidity_holding), + ); + let fresh = [ + first_fresh.then_some(input.first_token_holding.account_id()), + second_fresh.then_some(input.second_token_holding.account_id()), + ] + .into_iter() + .flatten() + .collect(); + let funding = vec![funding( + &liquidity_holding, + prepared.remove_liquidity_amount, + )]; + + PreparedTransaction::new( + TransactionOperation::RemoveLiquidity, + TransactionIntent::RemoveLiquidity { + first_token_definition_id: input.first_token_definition_id, + second_token_definition_id: input.second_token_definition_id, + remove_liquidity_amount: input.remove_liquidity_amount, + slippage_bps: input.slippage.bps(), + expected_fee_bps: input.expected_fee_bps, + }, + prepared.quote, + plan, + sources, + fresh, + funding, + CallerAmounts::new(caller_amounts.0, caller_amounts.1), + input.deadline, + None, + ) +} + +/// Quotes an exact-input swap and emits one exact swap plan. +pub fn prepare_swap_exact_input_transaction( + input: SwapExactInputTransactionInput<'_>, +) -> Result, TransactionError> { + let (context, active) = input.pool_accounts.validate( + input.amm_program_id, + input.input_token_definition_id, + input.output_token_definition_id, + )?; + let snapshot = active.pool(); + validate_expected_fee(snapshot, input.expected_fee_bps)?; + let (input_definition, output_definition) = swap_definitions( + snapshot, + input.input_token_definition_id, + input.output_token_definition_id, + )?; + let input_holding = + ValidatedFungibleHolding::new(&context, input.input_holding, input_definition)?; + let output_holding = + ValidatedFungibleHolding::new(&context, input.output_holding, output_definition)?; + let prepared = prepare_swap_exact_input( + snapshot, + &input_holding, + &output_holding, + input.amount_in, + input.slippage, + )?; + let spot_change = pool_spot_change_bps(snapshot.pool(), &prepared.quote)?; + let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?; + let plan = plan_swap_exact_input(SwapExactInputPlanInput { + context: &context, + pool, + user_input_holding: input_holding.account_id(), + user_output_holding: output_holding.account_id(), + swap_amount_in: prepared.swap_amount_in, + min_amount_out: prepared.min_amount_out, + deadline: input.deadline, + }); + let sources = pool_sources( + input.pool_accounts, + input.input_holding, + input.output_holding, + None, + ); + let funding = vec![funding(&input_holding, prepared.quote.amount_in)]; + + PreparedTransaction::new( + TransactionOperation::SwapExactInput, + TransactionIntent::SwapExactInput { + input_token_definition_id: input.input_token_definition_id, + output_token_definition_id: input.output_token_definition_id, + amount_in: input.amount_in, + slippage_bps: input.slippage.bps(), + expected_fee_bps: input.expected_fee_bps, + }, + prepared.quote, + plan, + sources, + Vec::new(), + funding, + CallerAmounts::new(prepared.quote.amount_in, prepared.quote.amount_out), + input.deadline, + Some(spot_change), + ) +} + +/// Quotes an exact-output swap and emits one exact swap plan. +pub fn prepare_swap_exact_output_transaction( + input: SwapExactOutputTransactionInput<'_>, +) -> Result, TransactionError> { + let (context, active) = input.pool_accounts.validate( + input.amm_program_id, + input.input_token_definition_id, + input.output_token_definition_id, + )?; + let snapshot = active.pool(); + validate_expected_fee(snapshot, input.expected_fee_bps)?; + let (input_definition, output_definition) = swap_definitions( + snapshot, + input.input_token_definition_id, + input.output_token_definition_id, + )?; + let input_holding = + ValidatedFungibleHolding::new(&context, input.input_holding, input_definition)?; + let output_holding = + ValidatedFungibleHolding::new(&context, input.output_holding, output_definition)?; + let prepared = prepare_swap_exact_output( + snapshot, + &input_holding, + &output_holding, + input.exact_amount_out, + input.slippage, + )?; + let spot_change = pool_spot_change_bps(snapshot.pool(), &prepared.quote)?; + let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?; + let plan = plan_swap_exact_output(SwapExactOutputPlanInput { + context: &context, + pool, + user_input_holding: input_holding.account_id(), + user_output_holding: output_holding.account_id(), + exact_amount_out: prepared.exact_amount_out, + max_amount_in: prepared.max_amount_in, + deadline: input.deadline, + }); + let sources = pool_sources( + input.pool_accounts, + input.input_holding, + input.output_holding, + None, + ); + let funding = vec![funding(&input_holding, prepared.max_amount_in)]; + + PreparedTransaction::new( + TransactionOperation::SwapExactOutput, + TransactionIntent::SwapExactOutput { + input_token_definition_id: input.input_token_definition_id, + output_token_definition_id: input.output_token_definition_id, + exact_amount_out: input.exact_amount_out, + slippage_bps: input.slippage.bps(), + expected_fee_bps: input.expected_fee_bps, + }, + prepared.quote, + plan, + sources, + Vec::new(), + funding, + CallerAmounts::new(prepared.quote.amount_in, prepared.quote.amount_out), + input.deadline, + Some(spot_change), + ) +} + +impl PreparedTransaction { + #[expect( + clippy::too_many_arguments, + reason = "prepared transaction construction binds each externally visible artifact" + )] + fn new( + operation: TransactionOperation, + intent: TransactionIntent, + quote: Q, + plan: TransactionPlan, + sources: Vec, + fresh_account_ids: Vec, + funding: Vec, + caller_amounts: CallerAmounts, + deadline: u64, + pool_spot_change_bps: Option, + ) -> Result { + let mut unique_account_ids = Vec::new(); + for account_id in plan.account_ids() { + if unique_account_ids.contains(&account_id) { + return Err(TransactionError::DuplicateAccountId { account_id }); + } + unique_account_ids.push(account_id); + } + + let instruction_words = plan + .instruction_data() + .map_err(|_| TransactionError::InstructionEncoding)?; + let plan_accounts = plan + .accounts() + .iter() + .map(|account| PlanAccountCommitment { + role: String::from(account.role().as_str()), + account_id: account.id(), + writable: account.writable(), + signer: account.signer(), + init: account.init(), + }) + .collect(); + let payload = PreparedCommitmentPayload { + domain: String::from(COMMITMENT_DOMAIN), + operation, + intent, + program_id: plan.program_id(), + instruction_words, + plan_accounts, + sources, + caller_amounts, + deadline, + }; + let words = risc0_zkvm::serde::to_vec(&payload) + .map_err(|_| TransactionError::CommitmentEncoding)?; + let mut bytes = Vec::with_capacity(words.len().saturating_mul(4)); + for word in words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let digest = Impl::hash_bytes(&bytes); + let mut commitment_bytes = [0_u8; 32]; + commitment_bytes.copy_from_slice(digest.as_bytes()); + let affected_account_ids = plan.affected_account_ids(); + let wallet_prerequisites = WalletPrerequisites { + signer_account_ids: plan.signer_account_ids(), + fresh_account_ids, + funding, + }; + + Ok(Self { + operation, + quote, + plan, + quote_commitment: QuoteCommitment(commitment_bytes), + affected_account_ids, + wallet_prerequisites, + caller_amounts, + deadline, + pool_spot_change_bps, + }) + } +} + +#[derive(Serialize)] +struct PreparedCommitmentPayload { + domain: String, + operation: TransactionOperation, + intent: TransactionIntent, + program_id: ProgramId, + instruction_words: Vec, + plan_accounts: Vec, + sources: Vec, + caller_amounts: CallerAmounts, + deadline: u64, +} + +#[derive(Serialize)] +enum TransactionIntent { + CreatePool { + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + first_amount: u128, + second_amount: u128, + fee_bps: u128, + }, + AddLiquidity { + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + max_first_amount: u128, + max_second_amount: u128, + slippage_bps: u128, + expected_fee_bps: Option, + }, + RemoveLiquidity { + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + remove_liquidity_amount: u128, + slippage_bps: u128, + expected_fee_bps: Option, + }, + SwapExactInput { + input_token_definition_id: AccountId, + output_token_definition_id: AccountId, + amount_in: u128, + slippage_bps: u128, + expected_fee_bps: Option, + }, + SwapExactOutput { + input_token_definition_id: AccountId, + output_token_definition_id: AccountId, + exact_amount_out: u128, + slippage_bps: u128, + expected_fee_bps: Option, + }, +} + +#[derive(Serialize)] +struct PlanAccountCommitment { + role: String, + account_id: AccountId, + writable: bool, + signer: bool, + init: bool, +} + +#[derive(Clone, Copy, Serialize)] +enum SnapshotRole { + Config, + Pool, + CallerFirstDefinition, + CallerSecondDefinition, + CallerFirstVault, + CallerSecondVault, + LiquidityDefinition, + LpLockHolding, + CallerFirstHolding, + CallerSecondHolding, + LiquidityHolding, +} + +#[derive(Serialize)] +struct SnapshotCommitment { + role: SnapshotRole, + account_id: AccountId, + commitment: [u8; 32], +} + +fn source(role: SnapshotRole, snapshot: &AccountSnapshot) -> SnapshotCommitment { + SnapshotCommitment { + role, + account_id: snapshot.account_id(), + commitment: Commitment::new(&snapshot.account_id(), snapshot.account()).to_byte_array(), + } +} + +fn pool_sources( + pool: PoolAccountSnapshots<'_>, + first_holding: &AccountSnapshot, + second_holding: &AccountSnapshot, + liquidity_holding: Option<&AccountSnapshot>, +) -> Vec { + pair_sources( + pool.config, + pool.pair, + first_holding, + second_holding, + liquidity_holding, + ) +} + +fn pair_sources( + config: &AccountSnapshot, + pair: PairReadSnapshots<'_>, + first_holding: &AccountSnapshot, + second_holding: &AccountSnapshot, + liquidity_holding: Option<&AccountSnapshot>, +) -> Vec { + let mut sources = vec![ + source(SnapshotRole::Config, config), + source(SnapshotRole::Pool, pair.pool), + source( + SnapshotRole::CallerFirstDefinition, + pair.first_token_definition, + ), + source( + SnapshotRole::CallerSecondDefinition, + pair.second_token_definition, + ), + source(SnapshotRole::CallerFirstVault, pair.first_token_vault), + source(SnapshotRole::CallerSecondVault, pair.second_token_vault), + source(SnapshotRole::LiquidityDefinition, pair.liquidity_definition), + source(SnapshotRole::LpLockHolding, pair.lp_lock_holding), + source(SnapshotRole::CallerFirstHolding, first_holding), + source(SnapshotRole::CallerSecondHolding, second_holding), + ]; + if let Some(liquidity_holding) = liquidity_holding { + sources.push(source(SnapshotRole::LiquidityHolding, liquidity_holding)); + } + sources +} + +fn caller_definitions( + snapshot: &ValidatedPoolSnapshot, + order: PairOrder, +) -> (&ValidatedFungibleDefinition, &ValidatedFungibleDefinition) { + match order { + PairOrder::Stored => (snapshot.token_a_definition(), snapshot.token_b_definition()), + PairOrder::Reversed => (snapshot.token_b_definition(), snapshot.token_a_definition()), + } +} + +fn validate_expected_fee( + snapshot: &ValidatedPoolSnapshot, + expected_fee_bps: Option, +) -> Result<(), TransactionError> { + if let Some(expected) = expected_fee_bps { + let actual = snapshot.pool().fees; + if expected != actual { + return Err(TransactionError::FeeMismatch { expected, actual }); + } + } + Ok(()) +} + +fn stored_holdings<'a>( + order: PairOrder, + first: &'a ValidatedFungibleHolding, + second: &'a ValidatedFungibleHolding, +) -> (&'a ValidatedFungibleHolding, &'a ValidatedFungibleHolding) { + match order { + PairOrder::Stored => (first, second), + PairOrder::Reversed => (second, first), + } +} + +fn order_pair(order: PairOrder, first: T, second: T) -> (T, T) { + match order { + PairOrder::Stored => (first, second), + PairOrder::Reversed => (second, first), + } +} + +fn swap_definitions( + snapshot: &ValidatedPoolSnapshot, + input_definition_id: AccountId, + output_definition_id: AccountId, +) -> Result<(&ValidatedFungibleDefinition, &ValidatedFungibleDefinition), ClientError> { + match amm_program::quote::pair_order( + snapshot.pool(), + input_definition_id, + output_definition_id, + )? { + PairOrder::Stored => Ok((snapshot.token_a_definition(), snapshot.token_b_definition())), + PairOrder::Reversed => Ok((snapshot.token_b_definition(), snapshot.token_a_definition())), + } +} + +fn validate_holding_destination( + context: &AmmContext, + snapshot: &AccountSnapshot, + expected_definition_id: AccountId, + account_name: &'static str, +) -> Result { + if snapshot.account() == &Account::default() { + return Ok(true); + } + if snapshot.account().program_owner != context.token_program_id() { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected: context.token_program_id(), + actual: snapshot.account().program_owner, + }); + } + let holding = TokenHolding::try_from(&snapshot.account().data).map_err(|_| { + ClientError::InvalidAccountData { + account: account_name, + expected: "fungible TokenHolding", + } + })?; + let TokenHolding::Fungible { definition_id, .. } = holding else { + return Err(ClientError::ExpectedFungibleToken { + account: account_name, + }); + }; + if definition_id != expected_definition_id { + return Err(ClientError::TokenDefinitionMismatch { + account: account_name, + expected: expected_definition_id, + actual: definition_id, + }); + } + Ok(false) +} + +fn ensure_funded( + holding: &ValidatedFungibleHolding, + required: u128, + account_name: &'static str, +) -> Result<(), ClientError> { + if holding.balance() < required { + return Err(ClientError::InsufficientBalance { + account: account_name, + available: holding.balance(), + required, + }); + } + Ok(()) +} + +fn funding(holding: &ValidatedFungibleHolding, required: u128) -> FundingRequirement { + FundingRequirement { + holding_account_id: holding.account_id(), + token_definition_id: holding.definition_id(), + available: holding.balance(), + required, + } +} diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs index 7d65e5c9..ab9625d8 100644 --- a/programs/amm/client/src/wire.rs +++ b/programs/amm/client/src/wire.rs @@ -3,7 +3,8 @@ use std::{error::Error, fmt, str::FromStr}; use amm_core::{ - AmmConfig, PoolDefinition, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, SUPPORTED_FEE_TIERS, + AmmConfig, Instruction, PoolDefinition, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, + SUPPORTED_FEE_TIERS, }; use amm_program::quote::{ AddLiquidityQuote, CreatePoolQuote, OraclePriceAccountQuote, PairOrder, PoolUpdate, @@ -17,6 +18,7 @@ use serde::Deserialize; use serde_json::{json, Value}; use crate::{ + discovery::{self, CanonicalPair, PairReadManifest}, plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input, plan_swap_exact_output, plan_sync_reserves, plan_update_config, @@ -25,13 +27,21 @@ use crate::{ ValidatedFungibleHolding, ValidatedPoolSnapshot, }, AddLiquidityPlanInput, AmmContext, ClientError, CreateOraclePriceAccountPlanInput, - CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, PoolContext, - PreparedAddLiquidity, PreparedCreatePool, PreparedRemoveLiquidity, PreparedSwapExactInput, - PreparedSwapExactOutput, RemoveLiquidityPlanInput, SlippageTolerance, SwapExactInputPlanInput, - SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, + CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, IntentError, + OpeningLiquidityIntent, PoolContext, PreparedAddLiquidity, PreparedCallerOpeningPair, + PreparedCreatePool, PreparedOpeningPair, PreparedRemoveLiquidity, PreparedSwapExactInput, + PreparedSwapExactOutput, PreparedTransaction, RemoveLiquidityPlanInput, SlippageTolerance, + SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionError, + TransactionOperation, TransactionPlan, UpdateConfigPlanInput, WalletPrerequisites, SLIPPAGE_BPS_DENOMINATOR, }; +/// Version of the reusable AMM client JSON contract. +/// +/// This identifies client payload shape only. It is intentionally unrelated to a deployed AMM +/// ProgramId, ImageID, or release version. +pub const WIRE_SCHEMA: &str = "amm-client.v1"; + /// Stable transport failure returned by the JSON and C ABI adapters. #[derive(Clone, Debug, Eq, PartialEq)] pub struct WireError { @@ -68,6 +78,18 @@ impl From for WireError { } } +impl From for WireError { + fn from(error: IntentError) -> Self { + Self::new(error.code(), error.to_string()) + } +} + +impl From for WireError { + fn from(error: TransactionError) -> Self { + Self::new(error.code(), error.to_string()) + } +} + #[derive(Deserialize)] #[serde(tag = "operation", rename_all = "snake_case")] enum PlanRequest { @@ -257,6 +279,196 @@ impl PoolInput { #[serde(tag = "operation", rename_all = "snake_case")] enum QuoteRequest { ProtocolConstants, + DeriveConfigId { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + }, + InspectConfig { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + }, + CanonicalPair { + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + }, + DerivePairReadManifest { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + }, + InspectPair { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + snapshots: PairReadSnapshotsInput, + }, + PrepareCallerOpeningPair { + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + intent: OpeningLiquidityIntentInput, + }, + PrepareCreatePoolTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "firstTokenHolding")] + first_token_holding: AccountSnapshotInput, + #[serde(rename = "secondTokenHolding")] + second_token_holding: AccountSnapshotInput, + #[serde(rename = "liquidityHolding")] + liquidity_holding: AccountSnapshotInput, + #[serde(rename = "firstAmount")] + first_amount: String, + #[serde(rename = "secondAmount")] + second_amount: String, + #[serde(rename = "feeBps")] + fee_bps: String, + deadline: String, + }, + PrepareAddLiquidityTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "firstTokenHolding")] + first_token_holding: AccountSnapshotInput, + #[serde(rename = "secondTokenHolding")] + second_token_holding: AccountSnapshotInput, + #[serde(rename = "liquidityHolding")] + liquidity_holding: AccountSnapshotInput, + #[serde(rename = "maxFirstAmount")] + max_first_amount: String, + #[serde(rename = "maxSecondAmount")] + max_second_amount: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + #[serde(rename = "expectedFeeBps")] + expected_fee_bps: Option, + deadline: String, + }, + PrepareRemoveLiquidityTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "firstTokenHolding")] + first_token_holding: AccountSnapshotInput, + #[serde(rename = "secondTokenHolding")] + second_token_holding: AccountSnapshotInput, + #[serde(rename = "liquidityHolding")] + liquidity_holding: AccountSnapshotInput, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + #[serde(rename = "expectedFeeBps")] + expected_fee_bps: Option, + deadline: String, + }, + PrepareSwapExactInputTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "outputTokenDefinitionId")] + output_token_definition_id: String, + #[serde(rename = "inputHolding")] + input_holding: AccountSnapshotInput, + #[serde(rename = "outputHolding")] + output_holding: AccountSnapshotInput, + #[serde(rename = "amountIn")] + amount_in: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + #[serde(rename = "expectedFeeBps")] + expected_fee_bps: Option, + deadline: String, + }, + PrepareSwapExactOutputTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "outputTokenDefinitionId")] + output_token_definition_id: String, + #[serde(rename = "inputHolding")] + input_holding: AccountSnapshotInput, + #[serde(rename = "outputHolding")] + output_holding: AccountSnapshotInput, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + #[serde(rename = "expectedFeeBps")] + expected_fee_bps: Option, + deadline: String, + }, + PrepareMinimumOpeningPair { + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + PrepareOpeningFromTokenA { + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + PrepareOpeningFromTokenB { + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + ValidateExplicitOpeningPair { + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, PairOrder { #[serde(flatten)] state: PoolStateInput, @@ -473,6 +685,103 @@ struct PoolSnapshotInput { liquidity_definition: AccountSnapshotInput, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PairReadSnapshotsInput { + pool: AccountSnapshotInput, + first_token_definition: AccountSnapshotInput, + second_token_definition: AccountSnapshotInput, + first_token_vault: AccountSnapshotInput, + second_token_vault: AccountSnapshotInput, + liquidity_definition: AccountSnapshotInput, + lp_lock_holding: AccountSnapshotInput, + current_tick: AccountSnapshotInput, + clock: AccountSnapshotInput, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum OpeningLiquidityIntentInput { + Minimum, + FirstAmount { + amount: String, + }, + SecondAmount { + amount: String, + }, + Explicit { + #[serde(rename = "firstAmount")] + first_amount: String, + #[serde(rename = "secondAmount")] + second_amount: String, + }, +} + +impl OpeningLiquidityIntentInput { + fn into_intent(self) -> Result { + Ok(match self { + Self::Minimum => OpeningLiquidityIntent::Minimum, + Self::FirstAmount { amount } => { + OpeningLiquidityIntent::FirstAmount(decimal_u128(&amount, "intent.amount")?) + } + Self::SecondAmount { amount } => { + OpeningLiquidityIntent::SecondAmount(decimal_u128(&amount, "intent.amount")?) + } + Self::Explicit { + first_amount, + second_amount, + } => OpeningLiquidityIntent::Explicit { + first_amount: decimal_u128(&first_amount, "intent.firstAmount")?, + second_amount: decimal_u128(&second_amount, "intent.secondAmount")?, + }, + }) + } +} + +struct OwnedPairReadSnapshots { + pool: AccountSnapshot, + first_token_definition: AccountSnapshot, + second_token_definition: AccountSnapshot, + first_token_vault: AccountSnapshot, + second_token_vault: AccountSnapshot, + liquidity_definition: AccountSnapshot, + lp_lock_holding: AccountSnapshot, + current_tick: AccountSnapshot, + clock: AccountSnapshot, +} + +impl PairReadSnapshotsInput { + fn into_snapshots(self) -> Result { + Ok(OwnedPairReadSnapshots { + pool: self.pool.into_snapshot()?, + first_token_definition: self.first_token_definition.into_snapshot()?, + second_token_definition: self.second_token_definition.into_snapshot()?, + first_token_vault: self.first_token_vault.into_snapshot()?, + second_token_vault: self.second_token_vault.into_snapshot()?, + liquidity_definition: self.liquidity_definition.into_snapshot()?, + lp_lock_holding: self.lp_lock_holding.into_snapshot()?, + current_tick: self.current_tick.into_snapshot()?, + clock: self.clock.into_snapshot()?, + }) + } +} + +impl OwnedPairReadSnapshots { + const fn as_borrowed(&self) -> discovery::PairReadSnapshots<'_> { + discovery::PairReadSnapshots { + pool: &self.pool, + first_token_definition: &self.first_token_definition, + second_token_definition: &self.second_token_definition, + first_token_vault: &self.first_token_vault, + second_token_vault: &self.second_token_vault, + liquidity_definition: &self.liquidity_definition, + lp_lock_holding: &self.lp_lock_holding, + current_tick: &self.current_tick, + clock: &self.clock, + } + } +} + impl PoolSnapshotInput { fn validate(self, context: &AmmContext) -> Result { let pool = self.pool.into_snapshot()?; @@ -521,8 +830,16 @@ impl AccountSnapshotInput { } } -/// Builds one of the ten canonical transaction plans from tagged JSON. +/// Builds a canonical low-level plan or prepares a snapshot-bound task transaction from JSON. pub fn plan_json(value: Value) -> Result { + validate_wire_schema(&value)?; + if value + .get("operation") + .and_then(Value::as_str) + .is_some_and(is_prepared_transaction_operation) + { + return quote_json(value); + } let request: PlanRequest = serde_json::from_value(value) .map_err(|error| invalid_request(format!("invalid plan request: {error}")))?; let plan = match request { @@ -722,14 +1039,26 @@ pub fn plan_json(value: Value) -> Result { } }; - transaction_plan_json(&plan) + versioned(transaction_plan_json(&plan)) +} + +fn is_prepared_transaction_operation(operation: &str) -> bool { + matches!( + operation, + "prepare_create_pool_transaction" + | "prepare_add_liquidity_transaction" + | "prepare_remove_liquidity_transaction" + | "prepare_swap_exact_input_transaction" + | "prepare_swap_exact_output_transaction" + ) } /// Evaluates one reusable AMM economic quote from tagged JSON. pub fn quote_json(value: Value) -> Result { + validate_wire_schema(&value)?; let request: QuoteRequest = serde_json::from_value(value) .map_err(|error| invalid_request(format!("invalid quote request: {error}")))?; - match request { + versioned(match request { QuoteRequest::ProtocolConstants => Ok(json!({ "minimumLiquidity": MINIMUM_LIQUIDITY.to_string(), "feeBpsDenominator": FEE_BPS_DENOMINATOR.to_string(), @@ -739,6 +1068,339 @@ pub fn quote_json(value: Value) -> Result { .map(u128::to_string) .collect::>(), })), + QuoteRequest::DeriveConfigId { amm_program_id } => Ok(json!({ + "configId": discovery::derive_config_id(amm_program_id).to_string(), + })), + QuoteRequest::InspectConfig { + amm_program_id, + config, + } => { + let config = config.into_snapshot()?; + let context = discovery::inspect_config(amm_program_id, &config)?; + Ok(amm_context_json(&context)) + } + QuoteRequest::CanonicalPair { + first_token_definition_id, + second_token_definition_id, + } => { + let pair = discovery::canonical_pair( + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + )?; + Ok(canonical_pair_json(pair)) + } + QuoteRequest::DerivePairReadManifest { + amm_program_id, + config, + first_token_definition_id, + second_token_definition_id, + } => { + let config = config.into_snapshot()?; + let context = discovery::inspect_config(amm_program_id, &config)?; + let manifest = discovery::derive_pair_read_manifest( + &context, + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + )?; + Ok(pair_read_manifest_json(manifest)) + } + QuoteRequest::InspectPair { + amm_program_id, + config, + first_token_definition_id, + second_token_definition_id, + snapshots, + } => { + let config = config.into_snapshot()?; + let context = discovery::inspect_config(amm_program_id, &config)?; + let snapshots = snapshots.into_snapshots()?; + let inspected = discovery::inspect_pair( + &context, + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + snapshots.as_borrowed(), + )?; + Ok(pair_inspection_json(inspected)) + } + QuoteRequest::PrepareCallerOpeningPair { + first_token_definition_id, + second_token_definition_id, + desired_price_q64_64, + fee_bps, + intent, + } => Ok(prepared_caller_opening_pair_json( + crate::prepare_caller_opening_pair( + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + intent.into_intent()?, + )?, + )), + QuoteRequest::PrepareCreatePoolTransaction { + amm_program_id, + config, + snapshots, + first_token_definition_id, + second_token_definition_id, + first_token_holding, + second_token_holding, + liquidity_holding, + first_amount, + second_amount, + fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let first_token_holding = first_token_holding.into_snapshot()?; + let second_token_holding = second_token_holding.into_snapshot()?; + let liquidity_holding = liquidity_holding.into_snapshot()?; + let prepared = + crate::prepare_create_pool_transaction(crate::CreatePoolTransactionInput { + amm_program_id, + config: &config, + pair: snapshots.as_borrowed(), + first_token_definition_id: account_id( + &first_token_definition_id, + "firstTokenDefinitionId", + )?, + second_token_definition_id: account_id( + &second_token_definition_id, + "secondTokenDefinitionId", + )?, + first_token_holding: &first_token_holding, + second_token_holding: &second_token_holding, + liquidity_holding: &liquidity_holding, + first_amount: decimal_u128(&first_amount, "firstAmount")?, + second_amount: decimal_u128(&second_amount, "secondAmount")?, + fee_bps: decimal_u128(&fee_bps, "feeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + })?; + prepared_transaction_json(&prepared, create_pool_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareAddLiquidityTransaction { + amm_program_id, + config, + snapshots, + first_token_definition_id, + second_token_definition_id, + first_token_holding, + second_token_holding, + liquidity_holding, + max_first_amount, + max_second_amount, + slippage_bps, + expected_fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let first_token_holding = first_token_holding.into_snapshot()?; + let second_token_holding = second_token_holding.into_snapshot()?; + let liquidity_holding = liquidity_holding.into_snapshot()?; + let prepared = + crate::prepare_add_liquidity_transaction(crate::AddLiquidityTransactionInput { + amm_program_id, + pool_accounts: crate::PoolAccountSnapshots { + config: &config, + pair: snapshots.as_borrowed(), + }, + first_token_definition_id: account_id( + &first_token_definition_id, + "firstTokenDefinitionId", + )?, + second_token_definition_id: account_id( + &second_token_definition_id, + "secondTokenDefinitionId", + )?, + first_token_holding: &first_token_holding, + second_token_holding: &second_token_holding, + liquidity_holding: &liquidity_holding, + max_first_amount: decimal_u128(&max_first_amount, "maxFirstAmount")?, + max_second_amount: decimal_u128(&max_second_amount, "maxSecondAmount")?, + slippage: slippage_tolerance(&slippage_bps)?, + expected_fee_bps: optional_decimal_u128(expected_fee_bps, "expectedFeeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + })?; + prepared_transaction_json(&prepared, add_liquidity_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareRemoveLiquidityTransaction { + amm_program_id, + config, + snapshots, + first_token_definition_id, + second_token_definition_id, + first_token_holding, + second_token_holding, + liquidity_holding, + remove_liquidity_amount, + slippage_bps, + expected_fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let first_token_holding = first_token_holding.into_snapshot()?; + let second_token_holding = second_token_holding.into_snapshot()?; + let liquidity_holding = liquidity_holding.into_snapshot()?; + let prepared = crate::prepare_remove_liquidity_transaction( + crate::RemoveLiquidityTransactionInput { + amm_program_id, + pool_accounts: crate::PoolAccountSnapshots { + config: &config, + pair: snapshots.as_borrowed(), + }, + first_token_definition_id: account_id( + &first_token_definition_id, + "firstTokenDefinitionId", + )?, + second_token_definition_id: account_id( + &second_token_definition_id, + "secondTokenDefinitionId", + )?, + first_token_holding: &first_token_holding, + second_token_holding: &second_token_holding, + liquidity_holding: &liquidity_holding, + remove_liquidity_amount: decimal_u128( + &remove_liquidity_amount, + "removeLiquidityAmount", + )?, + slippage: slippage_tolerance(&slippage_bps)?, + expected_fee_bps: optional_decimal_u128(expected_fee_bps, "expectedFeeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + }, + )?; + prepared_transaction_json(&prepared, remove_liquidity_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareSwapExactInputTransaction { + amm_program_id, + config, + snapshots, + input_token_definition_id, + output_token_definition_id, + input_holding, + output_holding, + amount_in, + slippage_bps, + expected_fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let input_holding = input_holding.into_snapshot()?; + let output_holding = output_holding.into_snapshot()?; + let prepared = crate::prepare_swap_exact_input_transaction( + crate::SwapExactInputTransactionInput { + amm_program_id, + pool_accounts: crate::PoolAccountSnapshots { + config: &config, + pair: snapshots.as_borrowed(), + }, + input_token_definition_id: account_id( + &input_token_definition_id, + "inputTokenDefinitionId", + )?, + output_token_definition_id: account_id( + &output_token_definition_id, + "outputTokenDefinitionId", + )?, + input_holding: &input_holding, + output_holding: &output_holding, + amount_in: decimal_u128(&amount_in, "amountIn")?, + slippage: slippage_tolerance(&slippage_bps)?, + expected_fee_bps: optional_decimal_u128(expected_fee_bps, "expectedFeeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + }, + )?; + prepared_transaction_json(&prepared, swap_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareSwapExactOutputTransaction { + amm_program_id, + config, + snapshots, + input_token_definition_id, + output_token_definition_id, + input_holding, + output_holding, + exact_amount_out, + slippage_bps, + expected_fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let input_holding = input_holding.into_snapshot()?; + let output_holding = output_holding.into_snapshot()?; + let prepared = crate::prepare_swap_exact_output_transaction( + crate::SwapExactOutputTransactionInput { + amm_program_id, + pool_accounts: crate::PoolAccountSnapshots { + config: &config, + pair: snapshots.as_borrowed(), + }, + input_token_definition_id: account_id( + &input_token_definition_id, + "inputTokenDefinitionId", + )?, + output_token_definition_id: account_id( + &output_token_definition_id, + "outputTokenDefinitionId", + )?, + input_holding: &input_holding, + output_holding: &output_holding, + exact_amount_out: decimal_u128(&exact_amount_out, "exactAmountOut")?, + slippage: slippage_tolerance(&slippage_bps)?, + expected_fee_bps: optional_decimal_u128(expected_fee_bps, "expectedFeeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + }, + )?; + prepared_transaction_json(&prepared, swap_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareMinimumOpeningPair { + desired_price_q64_64, + fee_bps, + } => Ok(prepared_opening_pair_json( + crate::prepare_minimum_opening_pair( + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + )?, + )), + QuoteRequest::PrepareOpeningFromTokenA { + token_a_amount, + desired_price_q64_64, + fee_bps, + } => Ok(prepared_opening_pair_json( + crate::prepare_opening_from_token_a( + decimal_u128(&token_a_amount, "tokenAAmount")?, + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + )?, + )), + QuoteRequest::PrepareOpeningFromTokenB { + token_b_amount, + desired_price_q64_64, + fee_bps, + } => Ok(prepared_opening_pair_json( + crate::prepare_opening_from_token_b( + decimal_u128(&token_b_amount, "tokenBAmount")?, + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + )?, + )), + QuoteRequest::ValidateExplicitOpeningPair { + token_a_amount, + token_b_amount, + desired_price_q64_64, + fee_bps, + } => Ok(prepared_opening_pair_json( + crate::validate_explicit_opening_pair( + decimal_u128(&token_a_amount, "tokenAAmount")?, + decimal_u128(&token_b_amount, "tokenBAmount")?, + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + )?, + )), QuoteRequest::PairOrder { state, first_token_definition_id, @@ -1074,7 +1736,7 @@ pub fn quote_json(value: Value) -> Result { )?, )) } - } + }) } fn transaction_plan_json(plan: &TransactionPlan) -> Result { @@ -1100,12 +1762,129 @@ fn transaction_plan_json(plan: &TransactionPlan) -> Result { Ok(json!({ "instruction": plan.instruction_name(), + "instructionArgs": instruction_args_json(plan.instruction()), "programId": plan.program_id(), "accounts": accounts, + "affectedAccountIds": plan + .affected_account_ids() + .into_iter() + .map(|id| id.to_string()) + .collect::>(), "instructionWords": instruction_words, })) } +fn instruction_args_json(instruction: &Instruction) -> Value { + match instruction { + Instruction::Initialize { + token_program_id, + twap_oracle_program_id, + authority, + } => json!({ + "tokenProgramId": token_program_id, + "twapOracleProgramId": twap_oracle_program_id, + "authority": authority.to_string(), + }), + Instruction::UpdateConfig { + token_program_id, + twap_oracle_program_id, + new_authority, + } => json!({ + "tokenProgramId": token_program_id, + "twapOracleProgramId": twap_oracle_program_id, + "newAuthority": new_authority.map(|authority| authority.to_string()), + }), + Instruction::CreatePriceObservations { window_duration } + | Instruction::CreateOraclePriceAccount { window_duration } => json!({ + "windowDuration": window_duration.to_string(), + }), + Instruction::NewDefinition { + token_a_amount, + token_b_amount, + fees, + deadline, + } => json!({ + "tokenAAmount": token_a_amount.to_string(), + "tokenBAmount": token_b_amount.to_string(), + "fees": fees.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + deadline, + } => json!({ + "minAmountLiquidity": min_amount_liquidity.to_string(), + "maxAmountToAddTokenA": max_amount_to_add_token_a.to_string(), + "maxAmountToAddTokenB": max_amount_to_add_token_b.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + deadline, + } => json!({ + "removeLiquidityAmount": remove_liquidity_amount.to_string(), + "minAmountToRemoveTokenA": min_amount_to_remove_token_a.to_string(), + "minAmountToRemoveTokenB": min_amount_to_remove_token_b.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + deadline, + } => json!({ + "swapAmountIn": swap_amount_in.to_string(), + "minAmountOut": min_amount_out.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + deadline, + } => json!({ + "exactAmountOut": exact_amount_out.to_string(), + "maxAmountIn": max_amount_in.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::SyncReserves => json!({}), + } +} + +fn validate_wire_schema(value: &Value) -> Result<(), WireError> { + let Some(schema) = value.get("schema") else { + return Ok(()); + }; + let Some(schema) = schema.as_str() else { + return Err(invalid_request("schema must be a string")); + }; + if schema == WIRE_SCHEMA { + Ok(()) + } else { + Err(WireError::new( + "unsupported_schema", + format!("unsupported AMM client schema {schema}"), + )) + } +} + +fn versioned(result: Result) -> Result { + let mut value = result?; + let Some(object) = value.as_object_mut() else { + return Err(WireError::new( + "response_serialization_failed", + "AMM client response must be a JSON object", + )); + }; + object.insert( + String::from("schema"), + Value::String(String::from(WIRE_SCHEMA)), + ); + Ok(value) +} + fn pool_definition<'a>( snapshot: &'a ValidatedPoolSnapshot, definition_id: AccountId, @@ -1163,6 +1942,209 @@ fn pool_update_json(pool: PoolUpdate) -> Value { }) } +fn amm_context_json(context: &AmmContext) -> Value { + json!({ + "ammProgramId": context.amm_program_id, + "configId": context.config_id().to_string(), + "tokenProgramId": context.token_program_id(), + "twapOracleProgramId": context.twap_oracle_program_id(), + "authority": context.config.authority.to_string(), + }) +} + +fn canonical_pair_json(pair: CanonicalPair) -> Value { + json!({ + "tokenAId": pair.token_a_id().to_string(), + "tokenBId": pair.token_b_id().to_string(), + }) +} + +fn pair_read_manifest_json(manifest: PairReadManifest) -> Value { + let first_token = manifest.first_token(); + let second_token = manifest.second_token(); + json!({ + "canonicalPair": canonical_pair_json(manifest.canonical_pair()), + "firstToken": { + "definitionId": first_token.definition_id().to_string(), + "vaultId": first_token.vault_id().to_string(), + }, + "secondToken": { + "definitionId": second_token.definition_id().to_string(), + "vaultId": second_token.vault_id().to_string(), + }, + "configId": manifest.config_id().to_string(), + "poolId": manifest.pool_id().to_string(), + "liquidityDefinitionId": manifest.liquidity_definition_id().to_string(), + "lpLockHoldingId": manifest.lp_lock_holding_id().to_string(), + "currentTickId": manifest.current_tick_id().to_string(), + "clockId": manifest.clock_id().to_string(), + }) +} + +fn pair_inspection_json(inspection: discovery::PairInspection) -> Value { + match inspection { + discovery::PairInspection::Missing(missing) => json!({ + "status": "missing", + "manifest": pair_read_manifest_json(missing.manifest()), + "firstTokenDefinition": fungible_definition_json(missing.first_token_definition()), + "secondTokenDefinition": fungible_definition_json(missing.second_token_definition()), + "firstVault": missing_vault_json(missing.first_vault()), + "secondVault": missing_vault_json(missing.second_vault()), + "clock": clock_json(missing.clock()), + }), + discovery::PairInspection::Active(active) => { + let snapshot = active.pool(); + let pool = snapshot.pool(); + json!({ + "status": "active", + "manifest": pair_read_manifest_json(active.manifest()), + "callerOrder": match active.caller_order() { + PairOrder::Stored => "stored", + PairOrder::Reversed => "reversed", + }, + "stored": { + "tokenADefinitionId": pool.definition_token_a_id.to_string(), + "tokenBDefinitionId": pool.definition_token_b_id.to_string(), + "vaultAId": pool.vault_a_id.to_string(), + "vaultBId": pool.vault_b_id.to_string(), + "liquidityDefinitionId": pool.liquidity_pool_id.to_string(), + "lpLockHoldingId": active.lp_lock_holding().account_id().to_string(), + "reserveA": pool.reserve_a.to_string(), + "reserveB": pool.reserve_b.to_string(), + "vaultABalance": snapshot.vault_a().balance().to_string(), + "vaultBBalance": snapshot.vault_b().balance().to_string(), + "liquidityPoolSupply": pool.liquidity_pool_supply.to_string(), + "lpLockBalance": active.lp_lock_holding().balance().to_string(), + "feeBps": pool.fees.to_string(), + }, + "storedSpotPriceQ64_64": active.stored_spot_price_q64_64().to_string(), + "currentTick": { + "tick": active.current_tick().tick.to_string(), + "lastUpdated": active.current_tick().last_updated.to_string(), + }, + "clock": clock_json(active.clock()), + }) + } + } +} + +fn fungible_definition_json(definition: &ValidatedFungibleDefinition) -> Value { + json!({ + "id": definition.account_id().to_string(), + "totalSupply": definition.total_supply().to_string(), + "authority": definition.authority().map(|authority| authority.to_string()), + }) +} + +fn missing_vault_json(vault: discovery::MissingVaultState) -> Value { + match vault { + discovery::MissingVaultState::Uninitialized => json!({ + "status": "uninitialized", + }), + discovery::MissingVaultState::ExistingFungible { balance } => json!({ + "status": "existing_fungible", + "balance": balance.to_string(), + }), + } +} + +fn clock_json(clock: discovery::ValidatedClockSnapshot) -> Value { + json!({ + "blockId": clock.block_id().to_string(), + "timestamp": clock.timestamp().to_string(), + }) +} + +fn prepared_opening_pair_json(prepared: PreparedOpeningPair) -> Value { + json!({ + "desiredPriceQ64_64": prepared.desired_price_q64_64.to_string(), + "actualPriceQ64_64": prepared.actual_price_q64_64.to_string(), + "tokenAAmount": prepared.token_a_amount.to_string(), + "tokenBAmount": prepared.token_b_amount.to_string(), + "feeBps": prepared.fee_bps.to_string(), + "quote": create_pool_quote_json(prepared.quote), + }) +} + +fn prepared_caller_opening_pair_json(prepared: PreparedCallerOpeningPair) -> Value { + json!({ + "callerOrder": match prepared.caller_order() { + PairOrder::Stored => "stored", + PairOrder::Reversed => "reversed", + }, + "firstAmount": prepared.first_amount().to_string(), + "secondAmount": prepared.second_amount().to_string(), + "stored": prepared_opening_pair_json(*prepared.stored()), + }) +} + +fn prepared_transaction_json( + prepared: &PreparedTransaction, + quote: Value, +) -> Result { + Ok(json!({ + "operation": transaction_operation_name(prepared.operation()), + "quote": quote, + "callerAmounts": { + "first": prepared.caller_amounts().first().to_string(), + "second": prepared.caller_amounts().second().to_string(), + }, + "plan": transaction_plan_json(prepared.plan())?, + "quoteCommitment": quote_commitment_hex(prepared.quote_commitment()), + "affectedAccountIds": prepared + .affected_account_ids() + .iter() + .map(ToString::to_string) + .collect::>(), + "walletPrerequisites": wallet_prerequisites_json(prepared.wallet_prerequisites()), + "deadline": prepared.deadline().to_string(), + "poolSpotChangeBps": prepared.pool_spot_change_bps().map(|bps| bps.to_string()), + })) +} + +const fn transaction_operation_name(operation: TransactionOperation) -> &'static str { + match operation { + TransactionOperation::CreatePool => "create_pool", + TransactionOperation::AddLiquidity => "add_liquidity", + TransactionOperation::RemoveLiquidity => "remove_liquidity", + TransactionOperation::SwapExactInput => "swap_exact_input", + TransactionOperation::SwapExactOutput => "swap_exact_output", + } +} + +fn quote_commitment_hex(commitment: crate::QuoteCommitment) -> String { + commitment + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn wallet_prerequisites_json(prerequisites: &WalletPrerequisites) -> Value { + json!({ + "signerAccountIds": prerequisites + .signer_account_ids() + .iter() + .map(ToString::to_string) + .collect::>(), + "freshAccountIds": prerequisites + .fresh_account_ids() + .iter() + .map(ToString::to_string) + .collect::>(), + "funding": prerequisites + .funding() + .iter() + .map(|requirement| json!({ + "holdingAccountId": requirement.holding_account_id().to_string(), + "tokenDefinitionId": requirement.token_definition_id().to_string(), + "available": requirement.available().to_string(), + "required": requirement.required().to_string(), + })) + .collect::>(), + }) +} + fn create_pool_quote_json(quote: CreatePoolQuote) -> Value { json!({ "pool": pool_update_json(quote.pool), @@ -1286,6 +2268,13 @@ fn decimal_u64(value: &str, field: &str) -> Result { decimal(value, field) } +fn optional_decimal_u128(value: Option, field: &str) -> Result, WireError> { + value + .as_deref() + .map(|value| decimal_u128(value, field)) + .transpose() +} + fn slippage_tolerance(value: &str) -> Result { Ok(SlippageTolerance::new(decimal_u128(value, "slippageBps")?)?) } diff --git a/programs/amm/client/tests/discovery_contract.rs b/programs/amm/client/tests/discovery_contract.rs new file mode 100644 index 00000000..278beed5 --- /dev/null +++ b/programs/amm/client/tests/discovery_contract.rs @@ -0,0 +1,396 @@ +use amm_client::{ + discovery::{ + canonical_pair, derive_config_id, derive_pair_read_manifest, inspect_config, inspect_pair, + MissingVaultState, PairInspection, PairReadSnapshots, + }, + quote::AccountSnapshot, +}; +use amm_core::{AmmConfig, PoolDefinition, FEE_TIER_BPS_30}; +use amm_program::quote::PairOrder; +use clock_core::ClockAccountData; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::CurrentTickAccount; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; + +fn lower_token_id() -> AccountId { + AccountId::new([1; 32]) +} + +fn higher_token_id() -> AccountId { + AccountId::new([2; 32]) +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn config_snapshot() -> AccountSnapshot { + AccountSnapshot::new( + derive_config_id(AMM_PROGRAM_ID), + account( + AMM_PROGRAM_ID, + Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }), + ), + ) +} + +fn fungible_definition( + id: AccountId, + total_supply: u128, + authority: Option, +) -> AccountSnapshot { + AccountSnapshot::new( + id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ), + ) +} + +fn fungible_holding( + id: AccountId, + program_owner: ProgramId, + definition_id: AccountId, + balance: u128, +) -> AccountSnapshot { + AccountSnapshot::new( + id, + account( + program_owner, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ), + ) +} + +fn clock_snapshot(id: AccountId) -> AccountSnapshot { + let data = ClockAccountData { + block_id: 123, + timestamp: 456, + } + .to_bytes(); + AccountSnapshot::new( + id, + account([88; 8], Data::try_from(data).expect("clock data must fit")), + ) +} + +#[test] +fn config_and_pair_discovery_are_canonical_and_caller_ordered() { + let config = config_snapshot(); + let context = inspect_config(AMM_PROGRAM_ID, &config).expect("config must validate"); + let forward = derive_pair_read_manifest(&context, lower_token_id(), higher_token_id()) + .expect("distinct pair must derive"); + let reverse = derive_pair_read_manifest(&context, higher_token_id(), lower_token_id()) + .expect("distinct pair must derive"); + + assert_eq!(derive_config_id(AMM_PROGRAM_ID), config.account_id()); + assert_eq!(context.token_program_id(), TOKEN_PROGRAM_ID); + assert_eq!(context.twap_oracle_program_id(), TWAP_ORACLE_PROGRAM_ID); + assert_eq!( + canonical_pair(lower_token_id(), higher_token_id()) + .expect("distinct pair must canonicalize") + .token_a_id(), + higher_token_id() + ); + assert_eq!(forward.pool_id(), reverse.pool_id()); + assert_eq!(forward.first_token().definition_id(), lower_token_id()); + assert_eq!(reverse.second_token().definition_id(), lower_token_id()); + assert_eq!( + forward.vault_id_for(lower_token_id()), + reverse.vault_id_for(lower_token_id()) + ); + assert_eq!(forward.config_id(), config.account_id()); + assert_eq!(forward.clock_id(), clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID); +} + +#[test] +fn missing_pair_allows_transfer_compatible_existing_vault() { + let context = inspect_config(AMM_PROGRAM_ID, &config_snapshot()).expect("config must validate"); + let manifest = derive_pair_read_manifest(&context, lower_token_id(), higher_token_id()) + .expect("pair must derive"); + let pool = AccountSnapshot::new(manifest.pool_id(), Account::default()); + let first_definition = fungible_definition(lower_token_id(), 10_000, None); + let second_definition = fungible_definition(higher_token_id(), 20_000, None); + let first_vault = fungible_holding( + manifest.first_token().vault_id(), + TOKEN_PROGRAM_ID, + lower_token_id(), + 7, + ); + let second_vault = AccountSnapshot::new(manifest.second_token().vault_id(), Account::default()); + let liquidity_definition = + AccountSnapshot::new(manifest.liquidity_definition_id(), Account::default()); + let lp_lock = AccountSnapshot::new(manifest.lp_lock_holding_id(), Account::default()); + let current_tick = AccountSnapshot::new(manifest.current_tick_id(), Account::default()); + let clock = clock_snapshot(manifest.clock_id()); + + let inspected = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .expect("current pool-creation preconditions must validate"); + + let PairInspection::Missing(missing) = inspected else { + panic!("default pool must inspect as missing"); + }; + assert_eq!( + missing.first_vault(), + MissingVaultState::ExistingFungible { balance: 7 } + ); + assert_eq!(missing.second_vault(), MissingVaultState::Uninitialized); + assert_eq!(missing.clock().block_id(), 123); + assert_eq!(missing.clock().timestamp(), 456); + + let foreign_vault = fungible_holding( + manifest.first_token().vault_id(), + [99; 8], + lower_token_id(), + 7, + ); + let error = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &foreign_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .err() + .expect("foreign-owned existing vault cannot be mutated by Token Program"); + assert!(matches!( + error, + amm_client::ClientError::ProgramOwnerMismatch { + account: "first token vault", + .. + } + )); +} + +#[test] +fn active_pair_maps_caller_order_to_stored_pool_order() { + let context = inspect_config(AMM_PROGRAM_ID, &config_snapshot()).expect("config must validate"); + let manifest = derive_pair_read_manifest(&context, lower_token_id(), higher_token_id()) + .expect("pair must derive"); + let lp_id = manifest.liquidity_definition_id(); + let pool_definition = PoolDefinition { + // Stored pool order is opposite the caller's lower/higher order. + definition_token_a_id: higher_token_id(), + definition_token_b_id: lower_token_id(), + vault_a_id: manifest.second_token().vault_id(), + vault_b_id: manifest.first_token().vault_id(), + liquidity_pool_id: lp_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + let pool = AccountSnapshot::new( + manifest.pool_id(), + account(AMM_PROGRAM_ID, Data::from(&pool_definition)), + ); + let first_definition = fungible_definition(lower_token_id(), 10_000, None); + let second_definition = fungible_definition(higher_token_id(), 20_000, None); + let first_vault = fungible_holding( + manifest.first_token().vault_id(), + TOKEN_PROGRAM_ID, + lower_token_id(), + 550, + ); + let second_vault = fungible_holding( + manifest.second_token().vault_id(), + TOKEN_PROGRAM_ID, + higher_token_id(), + 1_100, + ); + let liquidity_definition = fungible_definition(lp_id, 2_000, Some(lp_id)); + let lp_lock = fungible_holding( + manifest.lp_lock_holding_id(), + TOKEN_PROGRAM_ID, + lp_id, + 1_000, + ); + let current_tick = AccountSnapshot::new( + manifest.current_tick_id(), + account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 400, + }), + ), + ); + let clock = clock_snapshot(manifest.clock_id()); + + let inspected = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .expect("active pair must validate"); + + let PairInspection::Active(active) = inspected else { + panic!("initialized pool must inspect as active"); + }; + assert_eq!(active.caller_order(), PairOrder::Reversed); + assert_eq!( + active.pool().pool().definition_token_a_id, + higher_token_id() + ); + assert_eq!(active.pool().vault_a().balance(), 1_100); + assert_eq!(active.pool().vault_b().balance(), 550); + assert_eq!(active.pool().pool().liquidity_pool_supply, 2_000); + assert_eq!(active.pool().pool().fees, FEE_TIER_BPS_30); + assert_eq!(active.lp_lock_holding().balance(), 1_000); + assert_eq!(active.stored_spot_price_q64_64(), (1u128 << 64) / 2); + assert_eq!(active.current_tick().tick, -1); + + let donated_lp_lock = fungible_holding( + manifest.lp_lock_holding_id(), + TOKEN_PROGRAM_ID, + lp_id, + 1_001, + ); + let donated = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &donated_lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .expect("LP donated to the lock holding must not invalidate the pool"); + let PairInspection::Active(donated) = donated else { + panic!("initialized pool with extra locked LP must remain active"); + }; + assert_eq!(donated.lp_lock_holding().balance(), 1_001); + + let wrong_lp_lock = + fungible_holding(manifest.lp_lock_holding_id(), TOKEN_PROGRAM_ID, lp_id, 999); + let error = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &wrong_lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .err() + .expect("active pool must retain permanently locked minimum liquidity"); + assert!(matches!( + error, + amm_client::ClientError::InvalidAccountData { + account: "LP lock holding", + .. + } + )); +} + +#[test] +fn missing_pair_rejects_initialized_lp_dependency() { + let context = inspect_config(AMM_PROGRAM_ID, &config_snapshot()).expect("config must validate"); + let manifest = derive_pair_read_manifest(&context, lower_token_id(), higher_token_id()) + .expect("pair must derive"); + let pool = AccountSnapshot::new(manifest.pool_id(), Account::default()); + let first_definition = fungible_definition(lower_token_id(), 10_000, None); + let second_definition = fungible_definition(higher_token_id(), 20_000, None); + let first_vault = AccountSnapshot::new(manifest.first_token().vault_id(), Account::default()); + let second_vault = AccountSnapshot::new(manifest.second_token().vault_id(), Account::default()); + let lp_id = manifest.liquidity_definition_id(); + let liquidity_definition = fungible_definition(lp_id, 0, Some(lp_id)); + let lp_lock = AccountSnapshot::new(manifest.lp_lock_holding_id(), Account::default()); + let current_tick = AccountSnapshot::new(manifest.current_tick_id(), Account::default()); + let clock = clock_snapshot(manifest.clock_id()); + + let error = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .err() + .expect("Token Program requires LP definition to be uninitialized"); + + assert_eq!(error.code(), "invalid_account_data"); +} diff --git a/programs/amm/client/tests/ffi_contract.rs b/programs/amm/client/tests/ffi_contract.rs index ec7d0940..a8c9d380 100644 --- a/programs/amm/client/tests/ffi_contract.rs +++ b/programs/amm/client/tests/ffi_contract.rs @@ -5,7 +5,7 @@ use std::ffi::{c_char, CStr, CString}; -use amm_client::{amm_client_free, amm_client_plan, amm_client_quote}; +use amm_client::{amm_client_free, amm_client_plan, amm_client_quote, wire::WIRE_SCHEMA}; use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, @@ -95,6 +95,7 @@ fn fungible_holding(program_owner: ProgramId, definition_id: AccountId, balance: fn null_request_returns_structured_error() { let response = call(amm_client_plan, None); + assert_eq!(response["schema"], WIRE_SCHEMA); assert_eq!(response["ok"], false); assert_eq!(response["error"]["code"], "null_request"); } @@ -104,6 +105,7 @@ fn malformed_json_returns_structured_error() { let request = CString::new("{").expect("literal has no NUL"); let response = call(amm_client_quote, Some(&request)); + assert_eq!(response["schema"], WIRE_SCHEMA); assert_eq!(response["ok"], false); assert_eq!(response["error"]["code"], "invalid_json"); } @@ -130,7 +132,9 @@ fn protocol_constants_are_exposed_without_numeric_json_values() { &json!({"operation": "protocol_constants"}), ); + assert_eq!(response["schema"], WIRE_SCHEMA); assert_eq!(response["ok"], true); + assert_eq!(response["value"]["schema"], WIRE_SCHEMA); assert_eq!( response["value"]["minimumLiquidity"], MINIMUM_LIQUIDITY.to_string() diff --git a/programs/amm/client/tests/intent_contract.rs b/programs/amm/client/tests/intent_contract.rs new file mode 100644 index 00000000..9e37356b --- /dev/null +++ b/programs/amm/client/tests/intent_contract.rs @@ -0,0 +1,244 @@ +use amm_client::{ + caller_amounts_to_stored, paired_amount_from_token_a, paired_amount_from_token_b, + pool_spot_change_bps, prepare_caller_opening_pair, prepare_minimum_opening_pair, + prepare_opening_from_token_a, prepare_opening_from_token_b, stored_amounts_to_caller, + validate_explicit_opening_pair, IntentError, OpeningLiquidityIntent, Q64_64_ONE, +}; +use amm_core::{PoolDefinition, MINIMUM_LIQUIDITY}; +use amm_program::quote::{self as program_quote, PairOrder, SwapDirection}; +use nssa_core::account::AccountId; + +const FEE_BPS: u128 = 30; + +fn pool(reserve_a: u128, reserve_b: u128) -> PoolDefinition { + PoolDefinition { + definition_token_a_id: AccountId::new([1; 32]), + definition_token_b_id: AccountId::new([2; 32]), + vault_a_id: AccountId::new([3; 32]), + vault_b_id: AccountId::new([4; 32]), + liquidity_pool_id: AccountId::new([5; 32]), + liquidity_pool_supply: MINIMUM_LIQUIDITY + .checked_mul(100) + .expect("test liquidity supply fits u128"), + reserve_a, + reserve_b, + fees: FEE_BPS, + } +} + +#[test] +fn minimum_pair_handles_prices_below_equal_and_above_one() { + for price in [Q64_64_ONE - 1, Q64_64_ONE, Q64_64_ONE + 1] { + let prepared = prepare_minimum_opening_pair(price, FEE_BPS).unwrap(); + assert!(prepared.quote.user_liquidity > 0); + assert!(prepared.token_a_amount > 0); + assert!(prepared.token_b_amount > 0); + + if price >= Q64_64_ONE && prepared.token_a_amount > 1 { + let previous_a = prepared.token_a_amount.checked_sub(1).unwrap(); + let previous_b = paired_amount_from_token_a(previous_a, price).unwrap(); + assert!(program_quote::create_pool(previous_a, previous_b, FEE_BPS).is_err()); + } else if price < Q64_64_ONE && prepared.token_b_amount > 1 { + let previous_b = prepared.token_b_amount.checked_sub(1).unwrap(); + let previous_a = paired_amount_from_token_b(previous_b, price).unwrap(); + assert!(program_quote::create_pool(previous_a, previous_b, FEE_BPS).is_err()); + } + } +} + +#[test] +fn zero_price_and_zero_edited_amount_are_rejected() { + assert_eq!( + prepare_minimum_opening_pair(0, FEE_BPS), + Err(IntentError::ZeroDesiredPrice) + ); + assert_eq!( + paired_amount_from_token_a(0, Q64_64_ONE), + Err(IntentError::ZeroEditedAmount) + ); + assert_eq!( + paired_amount_from_token_b(1, 0), + Err(IntentError::ZeroDesiredPrice) + ); +} + +#[test] +fn pairing_uses_checked_widened_math_and_reports_overflow() { + assert_eq!( + paired_amount_from_token_a(u128::MAX, u128::MAX), + Err(IntentError::ArithmeticOverflow { + operation: "token-A to token-B pairing" + }) + ); + assert_eq!( + paired_amount_from_token_b(u128::MAX, 1), + Err(IntentError::ArithmeticOverflow { + operation: "token-B to token-A pairing" + }) + ); +} + +#[test] +fn paired_and_explicit_amounts_are_validated_by_program_quote() { + let from_a = prepare_opening_from_token_a(2_000, Q64_64_ONE * 2, FEE_BPS).unwrap(); + assert_eq!(from_a.token_b_amount, 4_000); + assert_eq!(from_a.quote.pool.reserve_b, 4_000); + + let from_b = prepare_opening_from_token_b(4_000, Q64_64_ONE * 2, FEE_BPS).unwrap(); + assert_eq!(from_b.token_a_amount, 2_000); + + let explicit = validate_explicit_opening_pair(2_000, 4_000, Q64_64_ONE * 2, FEE_BPS).unwrap(); + assert_eq!(explicit.actual_price_q64_64, Q64_64_ONE * 2); + + let mismatch = + validate_explicit_opening_pair(2_000, 4_001, Q64_64_ONE * 2, FEE_BPS).unwrap_err(); + assert!(matches!(mismatch, IntentError::SpotPriceMismatch { .. })); + + let too_small = prepare_opening_from_token_a(1, Q64_64_ONE, FEE_BPS).unwrap_err(); + assert!(matches!(too_small, IntentError::Quote { .. })); +} + +#[test] +fn amounts_above_javascript_integer_range_remain_exact() { + let amount_a = 1_u128 << 80; + let amount_b = amount_a.checked_mul(2).unwrap(); + let prepared = + validate_explicit_opening_pair(amount_a, amount_b, Q64_64_ONE * 2, FEE_BPS).unwrap(); + assert_eq!(prepared.token_a_amount, amount_a); + assert_eq!(prepared.token_b_amount, amount_b); + assert_eq!(prepared.quote.pool.reserve_a, amount_a); + assert_eq!(prepared.quote.pool.reserve_b, amount_b); +} + +#[test] +fn caller_and_stored_order_mapping_is_lossless() { + assert_eq!( + caller_amounts_to_stored(PairOrder::Stored, 11, 22), + (11, 22) + ); + assert_eq!( + caller_amounts_to_stored(PairOrder::Reversed, 11, 22), + (22, 11) + ); + assert_eq!( + stored_amounts_to_caller(PairOrder::Reversed, 22, 11), + (11, 22) + ); +} + +#[test] +fn caller_opening_intent_maps_both_token_orders_without_host_math() { + let lower = AccountId::new([1; 32]); + let higher = AccountId::new([2; 32]); + let desired_price = Q64_64_ONE.checked_mul(2).unwrap(); + + let reversed = prepare_caller_opening_pair( + lower, + higher, + desired_price, + FEE_BPS, + OpeningLiquidityIntent::FirstAmount(4_000), + ) + .unwrap(); + assert_eq!(reversed.caller_order(), PairOrder::Reversed); + assert_eq!(reversed.first_amount(), 4_000); + assert_eq!(reversed.second_amount(), 2_000); + assert_eq!(reversed.stored().token_a_amount, 2_000); + assert_eq!(reversed.stored().token_b_amount, 4_000); + + let stored = prepare_caller_opening_pair( + higher, + lower, + desired_price, + FEE_BPS, + OpeningLiquidityIntent::Explicit { + first_amount: 2_000, + second_amount: 4_000, + }, + ) + .unwrap(); + assert_eq!(stored.caller_order(), PairOrder::Stored); + assert_eq!(stored.first_amount(), 2_000); + assert_eq!(stored.second_amount(), 4_000); + + assert_eq!( + prepare_caller_opening_pair( + lower, + lower, + desired_price, + FEE_BPS, + OpeningLiquidityIntent::Minimum, + ), + Err(IntentError::IdenticalTokenDefinitions) + ); +} + +#[test] +fn pool_spot_change_is_directional_exact_and_floored_once() { + let before = pool(10_000, 20_000); + let quote = program_quote::preview_swap_exact_input( + &before, + before.reserve_a, + before.reserve_b, + SwapDirection::AToB, + 100, + ) + .unwrap(); + + assert_eq!(quote.pool.reserve_a, 10_100); + assert_eq!(quote.pool.reserve_b, 19_804); + assert_eq!(pool_spot_change_bps(&before, "e).unwrap(), 199); + + let large_quote = program_quote::preview_swap_exact_input( + &before, + before.reserve_a, + before.reserve_b, + SwapDirection::AToB, + 9_000, + ) + .unwrap(); + assert!(pool_spot_change_bps(&before, &large_quote).unwrap() > 10_000); +} + +#[test] +fn pool_spot_change_handles_reserves_above_javascript_range() { + let scale = 1_u128 << 60; + let reserve_a = scale.checked_mul(10).unwrap(); + let reserve_b = scale.checked_mul(20).unwrap(); + let before = pool(reserve_a, reserve_b); + let quote = program_quote::preview_swap_exact_input( + &before, + before.reserve_a, + before.reserve_b, + SwapDirection::BToA, + scale, + ) + .unwrap(); + + let change = pool_spot_change_bps(&before, "e).unwrap(); + assert!(change > 0); + assert!(change <= 10_000); +} + +#[test] +fn pool_spot_change_rejects_zero_directional_reserve() { + let valid_before = pool(10_000, 20_000); + let quote = program_quote::preview_swap_exact_input( + &valid_before, + valid_before.reserve_a, + valid_before.reserve_b, + SwapDirection::AToB, + 100, + ) + .unwrap(); + let zero_before = pool(0, 20_000); + + assert_eq!( + pool_spot_change_bps(&zero_before, "e), + Err(IntentError::ZeroDirectionalReserve) + ); + assert_eq!( + IntentError::ZeroDirectionalReserve.code(), + "zero_directional_reserve" + ); +} diff --git a/programs/amm/client/tests/plan_contract.rs b/programs/amm/client/tests/plan_contract.rs index ec52b5b9..e1ac0777 100644 --- a/programs/amm/client/tests/plan_contract.rs +++ b/programs/amm/client/tests/plan_contract.rs @@ -301,6 +301,25 @@ fn account_ids_and_signer_flags_stay_positionally_aligned() { } } +#[test] +fn affected_ids_are_unique_writable_accounts_in_instruction_order() { + for plan in all_plans() { + let expected = plan + .accounts() + .iter() + .filter(|account| account.writable()) + .map(|account| account.id()) + .fold(Vec::new(), |mut ids, id| { + if !ids.contains(&id) { + ids.push(id); + } + ids + }); + assert_eq!(plan.writable_account_ids(), expected); + assert_eq!(plan.affected_account_ids(), expected); + } +} + #[test] fn quote_results_feed_instruction_amounts_and_guards_without_recalculation() { let context = context(); diff --git a/programs/amm/client/tests/quote_contract.rs b/programs/amm/client/tests/quote_contract.rs index 549767ce..957e8381 100644 --- a/programs/amm/client/tests/quote_contract.rs +++ b/programs/amm/client/tests/quote_contract.rs @@ -539,16 +539,10 @@ fn prepared_instruction_args_feed_canonical_planners_without_ui_math() { let prepared_add = prepare_add_liquidity(&snapshot, 400, 100, tolerance).expect("add liquidity must prepare"); - assert_eq!(prepared_add.max_amount_to_add_token_a, 200); + assert_eq!(prepared_add.max_amount_to_add_token_a, 400); assert_eq!(prepared_add.max_amount_to_add_token_b, 100); - assert_eq!( - prepared_add.max_amount_to_add_token_a, - prepared_add.quote.actual_amount_a - ); - assert_eq!( - prepared_add.max_amount_to_add_token_b, - prepared_add.quote.actual_amount_b - ); + assert_eq!(prepared_add.quote.actual_amount_a, 200); + assert_eq!(prepared_add.quote.actual_amount_b, 100); let add_plan = plan_add_liquidity(AddLiquidityPlanInput { context: &fixture.context, pool, @@ -645,3 +639,51 @@ fn prepared_instruction_args_feed_canonical_planners_without_ui_math() { && *planned_deadline == deadline )); } + +#[test] +fn prepared_add_keeps_original_caps_across_non_idempotent_rounding() { + let mut fixture = Fixture::new(); + let pool_definition = PoolDefinition { + definition_token_a_id: token_a_id(), + definition_token_b_id: token_b_id(), + vault_a_id: vault_a_id(), + vault_b_id: vault_b_id(), + liquidity_pool_id: liquidity_definition_id(), + liquidity_pool_supply: 2_000, + reserve_a: 3, + reserve_b: 2, + fees: FEE_TIER_BPS_30, + }; + fixture.pool = AccountSnapshot::new( + pool_id(), + account(AMM_PROGRAM_ID, Data::from(&pool_definition)), + ); + fixture.vault_a = fungible_holding(vault_a_id(), token_a_id(), 3); + fixture.vault_b = fungible_holding(vault_b_id(), token_b_id(), 2); + let snapshot = fixture + .validated_pool() + .expect("non-divisible pool must validate"); + + let prepared = prepare_add_liquidity( + &snapshot, + 2, + 2, + SlippageTolerance::new(100).expect("one percent is valid"), + ) + .expect("original caps are executable"); + let executed = quote::add_liquidity( + &snapshot, + prepared.max_amount_to_add_token_a, + prepared.max_amount_to_add_token_b, + prepared.min_amount_liquidity, + ) + .expect("prepared instruction fields must execute"); + + assert_eq!(prepared.max_amount_to_add_token_a, 2); + assert_eq!(prepared.max_amount_to_add_token_b, 2); + assert_eq!(prepared.quote.actual_amount_a, 2); + assert_eq!(prepared.quote.actual_amount_b, 1); + assert_eq!(prepared.quote.liquidity_to_mint, 1_000); + assert_eq!(prepared.min_amount_liquidity, 990); + assert_eq!(executed, prepared.quote); +} diff --git a/programs/amm/client/tests/transaction_contract.rs b/programs/amm/client/tests/transaction_contract.rs new file mode 100644 index 00000000..0874efba --- /dev/null +++ b/programs/amm/client/tests/transaction_contract.rs @@ -0,0 +1,922 @@ +use amm_client::{ + transaction::{ + ensure_quote_unchanged, prepare_add_liquidity_transaction, prepare_create_pool_transaction, + prepare_remove_liquidity_transaction, prepare_swap_exact_input_transaction, + prepare_swap_exact_output_transaction, AddLiquidityTransactionInput, + CreatePoolTransactionInput, PoolAccountSnapshots, RemoveLiquidityTransactionInput, + SwapExactInputTransactionInput, SwapExactOutputTransactionInput, TransactionError, + }, + PairReadSnapshots, SlippageTolerance, +}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, +}; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; +const DEADLINE: u64 = 1_900_000_000_000; + +fn lower_token_id() -> AccountId { + AccountId::new([1; 32]) +} + +fn higher_token_id() -> AccountId { + AccountId::new([2; 32]) +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn definition(id: AccountId, total_supply: u128, authority: Option) -> AccountSnapshot { + AccountSnapshot::new( + id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ), + ) +} + +fn holding(id: AccountId, definition_id: AccountId, balance: u128) -> AccountSnapshot { + AccountSnapshot::new( + id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ), + ) +} + +fn clock_snapshot() -> AccountSnapshot { + let data = ClockAccountData { + block_id: 123, + timestamp: 456, + } + .to_bytes(); + AccountSnapshot::new( + CLOCK_01_PROGRAM_ACCOUNT_ID, + account([88; 8], Data::try_from(data).expect("clock data must fit")), + ) +} + +use amm_client::quote::AccountSnapshot; + +struct Fixture { + config: AccountSnapshot, + pool: AccountSnapshot, + stored_a_definition: AccountSnapshot, + stored_b_definition: AccountSnapshot, + vault_a: AccountSnapshot, + vault_b: AccountSnapshot, + liquidity_definition: AccountSnapshot, + lp_lock_holding: AccountSnapshot, + current_tick: AccountSnapshot, + clock: AccountSnapshot, + caller_first_holding: AccountSnapshot, + caller_second_holding: AccountSnapshot, + liquidity_holding: AccountSnapshot, +} + +impl Fixture { + fn new() -> Self { + // Pool storage is canonical descending ID order. Callers below deliberately use lower, + // higher order to prove the facade performs the mapping once. + let stored_a = higher_token_id(); + let stored_b = lower_token_id(); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, stored_a, stored_b); + let vault_a_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, stored_a); + let vault_b_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, stored_b); + let liquidity_id = compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id); + let lp_lock_id = compute_lp_lock_holding_pda(AMM_PROGRAM_ID, pool_id); + let current_tick_id = compute_current_tick_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id); + let config = AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }; + let pool = PoolDefinition { + definition_token_a_id: stored_a, + definition_token_b_id: stored_b, + vault_a_id, + vault_b_id, + liquidity_pool_id: liquidity_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + + Self { + config: AccountSnapshot::new( + compute_config_pda(AMM_PROGRAM_ID), + account(AMM_PROGRAM_ID, Data::from(&config)), + ), + pool: AccountSnapshot::new(pool_id, account(AMM_PROGRAM_ID, Data::from(&pool))), + stored_a_definition: definition(stored_a, 100_000, None), + stored_b_definition: definition(stored_b, 100_000, None), + vault_a: holding(vault_a_id, stored_a, 1_100), + vault_b: holding(vault_b_id, stored_b, 550), + liquidity_definition: definition(liquidity_id, 2_000, Some(liquidity_id)), + lp_lock_holding: holding(lp_lock_id, liquidity_id, 1_000), + current_tick: AccountSnapshot::new( + current_tick_id, + account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 400, + }), + ), + ), + clock: clock_snapshot(), + caller_first_holding: holding(AccountId::new([20; 32]), lower_token_id(), 10_000), + caller_second_holding: holding(AccountId::new([21; 32]), higher_token_id(), 10_000), + liquidity_holding: holding(AccountId::new([22; 32]), liquidity_id, 1_000), + } + } + + fn pool_accounts(&self) -> PoolAccountSnapshots<'_> { + self.pool_accounts_with(&self.config, &self.current_tick, &self.clock) + } + + fn pool_accounts_with<'a>( + &'a self, + config: &'a AccountSnapshot, + current_tick: &'a AccountSnapshot, + clock: &'a AccountSnapshot, + ) -> PoolAccountSnapshots<'a> { + PoolAccountSnapshots { + config, + pair: PairReadSnapshots { + pool: &self.pool, + first_token_definition: &self.stored_b_definition, + second_token_definition: &self.stored_a_definition, + first_token_vault: &self.vault_b, + second_token_vault: &self.vault_a, + liquidity_definition: &self.liquidity_definition, + lp_lock_holding: &self.lp_lock_holding, + current_tick, + clock, + }, + } + } + + fn stored_order_pool_accounts(&self) -> PoolAccountSnapshots<'_> { + PoolAccountSnapshots { + config: &self.config, + pair: PairReadSnapshots { + pool: &self.pool, + first_token_definition: &self.stored_a_definition, + second_token_definition: &self.stored_b_definition, + first_token_vault: &self.vault_a, + second_token_vault: &self.vault_b, + liquidity_definition: &self.liquidity_definition, + lp_lock_holding: &self.lp_lock_holding, + current_tick: &self.current_tick, + clock: &self.clock, + }, + } + } + + fn slippage() -> SlippageTolerance { + SlippageTolerance::new(100).expect("one-percent slippage must validate") + } +} + +struct MissingPairFixture { + pool: AccountSnapshot, + first_vault: AccountSnapshot, + second_vault: AccountSnapshot, + liquidity_definition: AccountSnapshot, + lp_lock_holding: AccountSnapshot, + current_tick: AccountSnapshot, + clock: AccountSnapshot, +} + +impl MissingPairFixture { + fn new(fixture: &Fixture) -> Self { + Self { + pool: AccountSnapshot::new(fixture.pool.account_id(), Account::default()), + first_vault: AccountSnapshot::new(fixture.vault_b.account_id(), Account::default()), + second_vault: AccountSnapshot::new(fixture.vault_a.account_id(), Account::default()), + liquidity_definition: AccountSnapshot::new( + fixture.liquidity_definition.account_id(), + Account::default(), + ), + lp_lock_holding: AccountSnapshot::new( + fixture.lp_lock_holding.account_id(), + Account::default(), + ), + current_tick: AccountSnapshot::new( + fixture.current_tick.account_id(), + Account::default(), + ), + clock: clock_snapshot(), + } + } + + fn pair<'a>(&'a self, fixture: &'a Fixture) -> PairReadSnapshots<'a> { + PairReadSnapshots { + pool: &self.pool, + first_token_definition: &fixture.stored_b_definition, + second_token_definition: &fixture.stored_a_definition, + first_token_vault: &self.first_vault, + second_token_vault: &self.second_vault, + liquidity_definition: &self.liquidity_definition, + lp_lock_holding: &self.lp_lock_holding, + current_tick: &self.current_tick, + clock: &self.clock, + } + } +} + +fn add_input<'a>( + fixture: &'a Fixture, + pool_accounts: PoolAccountSnapshots<'a>, + first_holding: &'a AccountSnapshot, + max_first_amount: u128, + max_second_amount: u128, + slippage_bps: u128, + expected_fee_bps: Option, +) -> AddLiquidityTransactionInput<'a> { + AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts, + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fixture.liquidity_holding, + max_first_amount, + max_second_amount, + slippage: SlippageTolerance::new(slippage_bps).expect("test slippage must validate"), + expected_fee_bps, + deadline: DEADLINE, + } +} + +#[test] +fn five_facades_emit_exact_plans_and_caller_order_amounts() { + let fixture = Fixture::new(); + let missing = MissingPairFixture::new(&fixture); + let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); + let create = prepare_create_pool_transaction(CreatePoolTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + config: &fixture.config, + pair: missing.pair(&fixture), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + first_amount: 4_000, + second_amount: 9_000, + fee_bps: FEE_TIER_BPS_30, + deadline: DEADLINE, + }) + .expect("funded create request must prepare"); + let Instruction::NewDefinition { + token_a_amount, + token_b_amount, + deadline, + .. + } = create.plan().instruction() + else { + panic!("create facade emitted wrong instruction") + }; + assert_eq!((*token_a_amount, *token_b_amount), (9_000, 4_000)); + assert_eq!(*deadline, DEADLINE); + assert_eq!(create.caller_amounts().first(), 4_000); + assert_eq!(create.caller_amounts().second(), 9_000); + assert_eq!( + create.wallet_prerequisites().fresh_account_ids(), + &[fresh_lp.account_id()] + ); + + let add = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fixture.liquidity_holding, + max_first_amount: 100, + max_second_amount: 400, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("funded add request must prepare"); + let Instruction::AddLiquidity { + max_amount_to_add_token_a, + max_amount_to_add_token_b, + .. + } = add.plan().instruction() + else { + panic!("add facade emitted wrong instruction") + }; + assert_eq!( + (*max_amount_to_add_token_a, *max_amount_to_add_token_b), + (400, 100) + ); + assert_eq!(add.caller_amounts().first(), 100); + assert_eq!(add.caller_amounts().second(), 200); + assert_eq!(add.wallet_prerequisites().funding()[0].required(), 100); + assert_eq!(add.wallet_prerequisites().funding()[1].required(), 400); + + let remove = prepare_remove_liquidity_transaction(RemoveLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fixture.liquidity_holding, + remove_liquidity_amount: 500, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("remove request must prepare"); + assert_eq!(remove.caller_amounts().first(), 125); + assert_eq!(remove.caller_amounts().second(), 250); + + let exact_input = prepare_swap_exact_input_transaction(SwapExactInputTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + input_token_definition_id: lower_token_id(), + output_token_definition_id: higher_token_id(), + input_holding: &fixture.caller_first_holding, + output_holding: &fixture.caller_second_holding, + amount_in: 100, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("exact-input swap must prepare"); + assert_eq!(exact_input.caller_amounts().first(), 100); + assert_eq!( + exact_input.caller_amounts().second(), + exact_input.quote().amount_out + ); + assert_eq!(exact_input.pool_spot_change_bps(), Some(4_371)); + + let exact_output = prepare_swap_exact_output_transaction(SwapExactOutputTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + input_token_definition_id: lower_token_id(), + output_token_definition_id: higher_token_id(), + input_holding: &fixture.caller_first_holding, + output_holding: &fixture.caller_second_holding, + exact_amount_out: 100, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("exact-output swap must prepare"); + assert_eq!( + exact_output.caller_amounts().first(), + exact_output.quote().amount_in + ); + assert_eq!(exact_output.caller_amounts().second(), 100); + assert!(exact_output.pool_spot_change_bps().is_some()); + let Instruction::SwapExactOutput { max_amount_in, .. } = exact_output.plan().instruction() + else { + panic!("exact-output facade emitted wrong instruction"); + }; + assert_eq!( + exact_output.wallet_prerequisites().funding()[0].required(), + *max_amount_in + ); + assert!(*max_amount_in > exact_output.quote().amount_in); + + for (plan, affected) in [ + (create.plan(), create.affected_account_ids()), + (add.plan(), add.affected_account_ids()), + (remove.plan(), remove.affected_account_ids()), + (exact_input.plan(), exact_input.affected_account_ids()), + (exact_output.plan(), exact_output.affected_account_ids()), + ] { + let words = plan + .instruction_data() + .expect("prepared instruction must encode"); + let decoded: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("guest codec must decode plan"); + assert_eq!( + risc0_zkvm::serde::to_vec(&decoded).expect("decoded instruction must encode"), + words + ); + assert_eq!(affected, plan.affected_account_ids()); + } +} + +#[test] +fn exact_output_requires_funding_through_its_maximum_input_guard() { + let fixture = Fixture::new(); + let funded = prepare_swap_exact_output_transaction(SwapExactOutputTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + input_token_definition_id: lower_token_id(), + output_token_definition_id: higher_token_id(), + input_holding: &fixture.caller_first_holding, + output_holding: &fixture.caller_second_holding, + exact_amount_out: 100, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("funded exact-output request must prepare"); + let quoted_input = funded.quote().amount_in; + let required = funded.wallet_prerequisites().funding()[0].required(); + assert!(required > quoted_input); + + let quote_only_balance = holding(AccountId::new([20; 32]), lower_token_id(), quoted_input); + let result = prepare_swap_exact_output_transaction(SwapExactOutputTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + input_token_definition_id: lower_token_id(), + output_token_definition_id: higher_token_id(), + input_holding: "e_only_balance, + output_holding: &fixture.caller_second_holding, + exact_amount_out: 100, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }); + let Err(error) = result else { + panic!("balance below maximum-input guard must fail"); + }; + assert!(matches!( + error, + TransactionError::Client(amm_client::ClientError::InsufficientBalance { + available, + required: actual_required, + .. + }) if available == quoted_input && actual_required == required + )); +} + +#[test] +fn commitment_is_stable_and_changes_with_bound_snapshot_or_deadline() { + let fixture = Fixture::new(); + let missing = MissingPairFixture::new(&fixture); + let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); + let prepare = |first_holding: &AccountSnapshot, deadline| { + prepare_create_pool_transaction(CreatePoolTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + config: &fixture.config, + pair: missing.pair(&fixture), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + first_amount: 4_000, + second_amount: 9_000, + fee_bps: FEE_TIER_BPS_30, + deadline, + }) + .expect("create request must prepare") + }; + + let first = prepare(&fixture.caller_first_holding, DEADLINE); + let repeated = prepare(&fixture.caller_first_holding, DEADLINE); + assert_eq!(first.quote_commitment(), repeated.quote_commitment()); + + let changed_holding = holding(AccountId::new([20; 32]), lower_token_id(), 10_001); + let changed_snapshot = prepare(&changed_holding, DEADLINE); + assert_ne!( + first.quote_commitment(), + changed_snapshot.quote_commitment() + ); + assert!(matches!( + ensure_quote_unchanged( + first.quote_commitment(), + changed_snapshot.quote_commitment() + ), + Err(TransactionError::QuoteChanged { .. }) + )); + + let changed_deadline = prepare(&fixture.caller_first_holding, DEADLINE + 1); + assert_ne!( + first.quote_commitment(), + changed_deadline.quote_commitment() + ); +} + +#[test] +fn create_and_add_reject_underfunded_selected_holdings() { + let fixture = Fixture::new(); + let missing = MissingPairFixture::new(&fixture); + let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); + let underfunded_first = holding(AccountId::new([20; 32]), lower_token_id(), 3_999); + let error = prepare_create_pool_transaction(CreatePoolTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + config: &fixture.config, + pair: missing.pair(&fixture), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &underfunded_first, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + first_amount: 4_000, + second_amount: 9_000, + fee_bps: FEE_TIER_BPS_30, + deadline: DEADLINE, + }) + .err() + .expect("underfunded create must fail"); + assert!(matches!( + error, + TransactionError::Client(amm_client::ClientError::InsufficientBalance { + required: 4_000, + .. + }) + )); + + // Expected transfer is 200, but the instruction may spend up to the caller's 400-unit cap. + let underfunded_second = holding(AccountId::new([21; 32]), higher_token_id(), 399); + let error = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &underfunded_second, + liquidity_holding: &fixture.liquidity_holding, + max_first_amount: 100, + max_second_amount: 400, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .err() + .expect("holding below the add spend cap must fail"); + assert!(matches!( + error, + TransactionError::Client(amm_client::ClientError::InsufficientBalance { + required: 400, + .. + }) + )); +} + +#[test] +fn add_accepts_only_explicit_default_snapshot_as_fresh_lp_destination() { + let fixture = Fixture::new(); + let fresh_lp = AccountSnapshot::new(AccountId::new([31; 32]), Account::default()); + let prepared = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + max_first_amount: 100, + max_second_amount: 400, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("explicit default LP snapshot must be accepted"); + assert_eq!( + prepared.wallet_prerequisites().fresh_account_ids(), + &[fresh_lp.account_id()] + ); + + let wrong_lp = holding(AccountId::new([31; 32]), lower_token_id(), 0); + let error = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + liquidity_holding: &wrong_lp, + ..AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + max_first_amount: 100, + max_second_amount: 400, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + } + }) + .err() + .expect("initialized holding for wrong definition must fail"); + assert_eq!(error.code(), "token_definition_mismatch"); +} + +#[test] +fn lifecycle_tick_clock_and_expected_fee_are_validated_before_planning() { + let fixture = Fixture::new(); + let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); + let active_create = prepare_create_pool_transaction(CreatePoolTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + config: &fixture.config, + pair: fixture.pool_accounts().pair, + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + first_amount: 4_000, + second_amount: 9_000, + fee_bps: FEE_TIER_BPS_30, + deadline: DEADLINE, + }) + .err() + .expect("active pool must not prepare as creation"); + assert_eq!(active_create.code(), "invalid_account_data"); + + let wrong_tick = AccountSnapshot::new( + AccountId::new([99; 32]), + fixture.current_tick.account().clone(), + ); + let error = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts_with(&fixture.config, &wrong_tick, &fixture.clock), + &fixture.caller_first_holding, + 100, + 400, + 100, + Some(FEE_TIER_BPS_30), + )) + .err() + .expect("mismatched current tick must fail"); + assert_eq!(error.code(), "account_id_mismatch"); + + let wrong_clock = + AccountSnapshot::new(AccountId::new([98; 32]), fixture.clock.account().clone()); + let error = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts_with(&fixture.config, &fixture.current_tick, &wrong_clock), + &fixture.caller_first_holding, + 100, + 400, + 100, + Some(FEE_TIER_BPS_30), + )) + .err() + .expect("mismatched clock must fail"); + assert_eq!(error.code(), "account_id_mismatch"); + + let mismatch = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 100, + Some(100), + )) + .err() + .expect("caller fee expectation must be checked"); + assert!(matches!( + mismatch, + TransactionError::FeeMismatch { + expected: 100, + actual: FEE_TIER_BPS_30, + } + )); + + let expected = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 100, + Some(FEE_TIER_BPS_30), + )) + .expect("matching expected fee must prepare"); + let unspecified = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 100, + None, + )) + .expect("unspecified expected fee must prepare from pool state"); + assert_eq!( + expected.plan().instruction_data(), + unspecified.plan().instruction_data() + ); + assert_eq!(expected.quote(), unspecified.quote()); +} + +#[test] +fn commitment_binds_intent_order_selection_and_quote_sources_only() { + let fixture = Fixture::new(); + let base = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("base add must prepare"); + + // One- and two-basis-point tolerances both floor this quote's minimum LP to the same value. + // The typed intent still distinguishes them. + let changed_slippage = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 2, + Some(FEE_TIER_BPS_30), + )) + .expect("changed slippage must prepare"); + assert_eq!( + base.plan().instruction_data(), + changed_slippage.plan().instruction_data() + ); + assert_ne!(base.quote_commitment(), changed_slippage.quote_commitment()); + + let changed_cap = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 101, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("changed cap must prepare"); + assert_ne!(base.quote_commitment(), changed_cap.quote_commitment()); + + let no_fee_expectation = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 1, + None, + )) + .expect("optional fee expectation must not alter quote logic"); + assert_eq!( + base.plan().instruction_data(), + no_fee_expectation.plan().instruction_data() + ); + assert_ne!( + base.quote_commitment(), + no_fee_expectation.quote_commitment() + ); + + let stored_order = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.stored_order_pool_accounts(), + first_token_definition_id: higher_token_id(), + second_token_definition_id: lower_token_id(), + first_token_holding: &fixture.caller_second_holding, + second_token_holding: &fixture.caller_first_holding, + liquidity_holding: &fixture.liquidity_holding, + max_first_amount: 400, + max_second_amount: 100, + slippage: SlippageTolerance::new(1).expect("test slippage"), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("stored caller order must prepare"); + assert_eq!( + base.plan().instruction_data(), + stored_order.plan().instruction_data() + ); + assert_ne!(base.quote_commitment(), stored_order.quote_commitment()); + + let alternate_holding = holding(AccountId::new([24; 32]), lower_token_id(), 10_000); + let changed_selection = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &alternate_holding, + 100, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("alternate funded holding must prepare"); + assert_ne!( + base.quote_commitment(), + changed_selection.quote_commitment() + ); + + let changed_config_data = AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([8; 32]), + }; + let changed_config = AccountSnapshot::new( + fixture.config.account_id(), + account(AMM_PROGRAM_ID, Data::from(&changed_config_data)), + ); + let changed_source = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts_with(&changed_config, &fixture.current_tick, &fixture.clock), + &fixture.caller_first_holding, + 100, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("non-economic config source change must prepare"); + assert_eq!( + base.plan().instruction_data(), + changed_source.plan().instruction_data() + ); + assert_ne!(base.quote_commitment(), changed_source.quote_commitment()); + + let changed_tick = AccountSnapshot::new( + fixture.current_tick.account_id(), + account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 401, + }), + ), + ); + let changed_clock_data = ClockAccountData { + block_id: 124, + timestamp: 457, + } + .to_bytes(); + let changed_clock = AccountSnapshot::new( + CLOCK_01_PROGRAM_ACCOUNT_ID, + account( + [88; 8], + Data::try_from(changed_clock_data).expect("clock data must fit"), + ), + ); + let ephemeral_change = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts_with(&fixture.config, &changed_tick, &changed_clock), + &fixture.caller_first_holding, + 100, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("valid tick and clock refresh must prepare"); + assert_eq!(base.quote_commitment(), ephemeral_change.quote_commitment()); +} + +#[test] +fn rejects_account_aliases_that_make_the_runtime_plan_unexecutable() { + let fixture = Fixture::new(); + let result = prepare_remove_liquidity_transaction(RemoveLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.vault_b, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fixture.liquidity_holding, + remove_liquidity_amount: 500, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }); + let Err(error) = result else { + panic!("holding aliases must not produce duplicate planned account IDs"); + }; + + assert_eq!( + error, + TransactionError::DuplicateAccountId { + account_id: fixture.vault_b.account_id(), + } + ); + assert_eq!(error.code(), "duplicate_account_id"); +} diff --git a/programs/amm/client/tests/wire_discovery_intent_contract.rs b/programs/amm/client/tests/wire_discovery_intent_contract.rs new file mode 100644 index 00000000..498d9fd1 --- /dev/null +++ b/programs/amm/client/tests/wire_discovery_intent_contract.rs @@ -0,0 +1,530 @@ +use amm_client::wire::{quote_json, WIRE_SCHEMA}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, AmmConfig, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, +}; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; +const Q64_64_ONE: u128 = 1_u128 << 64; + +fn config_snapshot() -> Value { + let config = AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }; + snapshot( + compute_config_pda(AMM_PROGRAM_ID), + &Account { + program_owner: AMM_PROGRAM_ID, + balance: 0, + data: Data::from(&config), + nonce: Nonce(0), + }, + ) +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn fungible_definition(total_supply: u128, authority: Option) -> Account { + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ) +} + +fn fungible_holding(program_owner: ProgramId, definition_id: AccountId, balance: u128) -> Account { + account( + program_owner, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ) +} + +fn clock_account() -> Account { + let bytes = ClockAccountData { + block_id: 123, + timestamp: 456, + } + .to_bytes(); + account( + [88; 8], + Data::try_from(bytes).expect("clock account data must fit"), + ) +} + +struct PairIds { + first_token_id: AccountId, + second_token_id: AccountId, + pool_id: AccountId, + first_vault_id: AccountId, + second_vault_id: AccountId, + liquidity_definition_id: AccountId, + lp_lock_holding_id: AccountId, + current_tick_id: AccountId, +} + +impl PairIds { + fn new() -> Self { + let first_token_id = AccountId::new([1; 32]); + let second_token_id = AccountId::new([2; 32]); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, second_token_id, first_token_id); + Self { + first_token_id, + second_token_id, + pool_id, + first_vault_id: compute_vault_pda(AMM_PROGRAM_ID, pool_id, first_token_id), + second_vault_id: compute_vault_pda(AMM_PROGRAM_ID, pool_id, second_token_id), + liquidity_definition_id: compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id), + lp_lock_holding_id: compute_lp_lock_holding_pda(AMM_PROGRAM_ID, pool_id), + current_tick_id: compute_current_tick_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id), + } + } + + fn inspect_request(&self, snapshots: Value) -> Value { + json!({ + "operation": "inspect_pair", + "ammProgramId": AMM_PROGRAM_ID, + "config": config_snapshot(), + "firstTokenDefinitionId": self.first_token_id.to_string(), + "secondTokenDefinitionId": self.second_token_id.to_string(), + "snapshots": snapshots, + }) + } + + fn missing_snapshots(&self) -> Value { + json!({ + "pool": snapshot(self.pool_id, &Account::default()), + "firstTokenDefinition": snapshot( + self.first_token_id, + &fungible_definition(10_000, None), + ), + "secondTokenDefinition": snapshot( + self.second_token_id, + &fungible_definition(20_000, None), + ), + "firstTokenVault": snapshot( + self.first_vault_id, + &fungible_holding(TOKEN_PROGRAM_ID, self.first_token_id, 7), + ), + "secondTokenVault": snapshot(self.second_vault_id, &Account::default()), + "liquidityDefinition": snapshot( + self.liquidity_definition_id, + &Account::default(), + ), + "lpLockHolding": snapshot(self.lp_lock_holding_id, &Account::default()), + "currentTick": snapshot(self.current_tick_id, &Account::default()), + "clock": snapshot(CLOCK_01_PROGRAM_ACCOUNT_ID, &clock_account()), + }) + } + + fn active_snapshots(&self) -> Value { + let pool = PoolDefinition { + definition_token_a_id: self.second_token_id, + definition_token_b_id: self.first_token_id, + vault_a_id: self.second_vault_id, + vault_b_id: self.first_vault_id, + liquidity_pool_id: self.liquidity_definition_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + json!({ + "pool": snapshot(self.pool_id, &account(AMM_PROGRAM_ID, Data::from(&pool))), + "firstTokenDefinition": snapshot( + self.first_token_id, + &fungible_definition(10_000, None), + ), + "secondTokenDefinition": snapshot( + self.second_token_id, + &fungible_definition(20_000, None), + ), + "firstTokenVault": snapshot( + self.first_vault_id, + &fungible_holding(TOKEN_PROGRAM_ID, self.first_token_id, 550), + ), + "secondTokenVault": snapshot( + self.second_vault_id, + &fungible_holding(TOKEN_PROGRAM_ID, self.second_token_id, 1_100), + ), + "liquidityDefinition": snapshot( + self.liquidity_definition_id, + &fungible_definition(2_000, Some(self.liquidity_definition_id)), + ), + "lpLockHolding": snapshot( + self.lp_lock_holding_id, + &fungible_holding( + TOKEN_PROGRAM_ID, + self.liquidity_definition_id, + MINIMUM_LIQUIDITY, + ), + ), + "currentTick": snapshot( + self.current_tick_id, + &account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 400, + }), + ), + ), + "clock": snapshot(CLOCK_01_PROGRAM_ACCOUNT_ID, &clock_account()), + }) + } +} + +fn snapshot(id: AccountId, account: &Account) -> Value { + json!({ + "id": id.to_string(), + "programOwner": account.program_owner, + "balance": account.balance.to_string(), + "nonce": account.nonce.0.to_string(), + "data": account + .data + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + }) +} + +fn assert_decimal_string(value: &Value) { + let text = value.as_str().expect("wire amount must be a string"); + assert!( + !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()), + "wire amount must be unsigned decimal" + ); +} + +#[test] +fn discovery_operations_return_exact_string_account_ids() { + let first_token_id = AccountId::new([1; 32]); + let second_token_id = AccountId::new([2; 32]); + + let config_id = quote_json(json!({ + "operation": "derive_config_id", + "ammProgramId": AMM_PROGRAM_ID, + })) + .expect("legacy schema-less request remains accepted"); + assert_eq!(config_id["schema"], WIRE_SCHEMA); + assert_eq!( + config_id["configId"], + compute_config_pda(AMM_PROGRAM_ID).to_string() + ); + + let config = config_snapshot(); + let inspected = quote_json(json!({ + "schema": WIRE_SCHEMA, + "operation": "inspect_config", + "ammProgramId": AMM_PROGRAM_ID, + "config": config.clone(), + })) + .expect("config must inspect"); + assert_eq!(inspected["schema"], WIRE_SCHEMA); + assert_eq!(inspected["ammProgramId"], json!(AMM_PROGRAM_ID)); + assert_eq!(inspected["tokenProgramId"], json!(TOKEN_PROGRAM_ID)); + assert_eq!( + inspected["twapOracleProgramId"], + json!(TWAP_ORACLE_PROGRAM_ID) + ); + assert_eq!(inspected["authority"], AccountId::new([9; 32]).to_string()); + + let canonical = quote_json(json!({ + "operation": "canonical_pair", + "firstTokenDefinitionId": first_token_id.to_string(), + "secondTokenDefinitionId": second_token_id.to_string(), + })) + .expect("distinct pair must canonicalize"); + assert_eq!(canonical["tokenAId"], second_token_id.to_string()); + assert_eq!(canonical["tokenBId"], first_token_id.to_string()); + + let manifest = quote_json(json!({ + "operation": "derive_pair_read_manifest", + "ammProgramId": AMM_PROGRAM_ID, + "config": config, + "firstTokenDefinitionId": first_token_id.to_string(), + "secondTokenDefinitionId": second_token_id.to_string(), + })) + .expect("pair read manifest must derive"); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, second_token_id, first_token_id); + assert_eq!(manifest["poolId"], pool_id.to_string()); + assert_eq!( + manifest["firstToken"]["definitionId"], + first_token_id.to_string() + ); + assert_eq!( + manifest["firstToken"]["vaultId"], + compute_vault_pda(AMM_PROGRAM_ID, pool_id, first_token_id).to_string() + ); + assert_eq!( + manifest["secondToken"]["vaultId"], + compute_vault_pda(AMM_PROGRAM_ID, pool_id, second_token_id).to_string() + ); + assert_eq!( + manifest["liquidityDefinitionId"], + compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id).to_string() + ); + assert_eq!( + manifest["lpLockHoldingId"], + compute_lp_lock_holding_pda(AMM_PROGRAM_ID, pool_id).to_string() + ); + assert_eq!( + manifest["currentTickId"], + compute_current_tick_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id).to_string() + ); + assert_eq!(manifest["clockId"], CLOCK_01_PROGRAM_ACCOUNT_ID.to_string()); +} + +#[test] +fn inspect_pair_reports_missing_caller_ordered_state() { + let ids = PairIds::new(); + let inspected = quote_json(ids.inspect_request(ids.missing_snapshots())) + .expect("missing pair snapshots must inspect"); + + assert_eq!(inspected["status"], "missing"); + assert_eq!(inspected["manifest"]["poolId"], ids.pool_id.to_string()); + assert_eq!( + inspected["firstTokenDefinition"]["id"], + ids.first_token_id.to_string() + ); + assert_eq!(inspected["firstTokenDefinition"]["totalSupply"], "10000"); + assert_eq!( + inspected["secondTokenDefinition"]["id"], + ids.second_token_id.to_string() + ); + assert_eq!(inspected["secondTokenDefinition"]["totalSupply"], "20000"); + assert_eq!(inspected["firstVault"]["status"], "existing_fungible"); + assert_eq!(inspected["firstVault"]["balance"], "7"); + assert_eq!(inspected["secondVault"]["status"], "uninitialized"); + assert_eq!(inspected["clock"]["blockId"], "123"); + assert_eq!(inspected["clock"]["timestamp"], "456"); +} + +#[test] +fn inspect_pair_reports_active_stored_state_for_reversed_caller_order() { + let ids = PairIds::new(); + let inspected = quote_json(ids.inspect_request(ids.active_snapshots())) + .expect("active pair snapshots must inspect"); + + assert_eq!(inspected["status"], "active"); + assert_eq!(inspected["callerOrder"], "reversed"); + assert_eq!( + inspected["stored"]["tokenADefinitionId"], + ids.second_token_id.to_string() + ); + assert_eq!( + inspected["stored"]["tokenBDefinitionId"], + ids.first_token_id.to_string() + ); + assert_eq!( + inspected["stored"]["vaultAId"], + ids.second_vault_id.to_string() + ); + assert_eq!( + inspected["stored"]["vaultBId"], + ids.first_vault_id.to_string() + ); + assert_eq!(inspected["stored"]["reserveA"], "1000"); + assert_eq!(inspected["stored"]["reserveB"], "500"); + assert_eq!(inspected["stored"]["vaultABalance"], "1100"); + assert_eq!(inspected["stored"]["vaultBBalance"], "550"); + assert_eq!(inspected["stored"]["liquidityPoolSupply"], "2000"); + assert_eq!(inspected["stored"]["lpLockBalance"], "1000"); + assert_eq!(inspected["stored"]["feeBps"], "30"); + assert_eq!( + inspected["storedSpotPriceQ64_64"], + (Q64_64_ONE / 2).to_string() + ); + assert_eq!(inspected["currentTick"]["tick"], "-1"); + assert_eq!(inspected["currentTick"]["lastUpdated"], "400"); + assert_eq!(inspected["clock"]["blockId"], "123"); + assert_eq!(inspected["clock"]["timestamp"], "456"); +} + +#[test] +fn inspect_pair_preserves_stable_snapshot_validation_errors() { + let ids = PairIds::new(); + let mut snapshots = ids.missing_snapshots(); + snapshots["pool"]["id"] = Value::String(AccountId::new([99; 32]).to_string()); + + let error = quote_json(ids.inspect_request(snapshots)) + .expect_err("wrong pool snapshot ID must fail before lifecycle inspection"); + assert_eq!(error.code(), "account_id_mismatch"); +} + +#[test] +fn opening_intent_operations_preserve_lossless_decimal_values() { + let desired_price = Q64_64_ONE.checked_mul(2).expect("test price fits"); + let fee_bps = FEE_TIER_BPS_30.to_string(); + + let minimum = quote_json(json!({ + "operation": "prepare_minimum_opening_pair", + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": fee_bps, + })) + .expect("minimum executable pair must prepare"); + for field in [ + "desiredPriceQ64_64", + "actualPriceQ64_64", + "tokenAAmount", + "tokenBAmount", + "feeBps", + ] { + assert_decimal_string(&minimum[field]); + } + assert_eq!( + minimum["quote"]["pool"]["reserveA"], + minimum["tokenAAmount"] + ); + assert_eq!( + minimum["quote"]["pool"]["reserveB"], + minimum["tokenBAmount"] + ); + + let from_a = quote_json(json!({ + "operation": "prepare_opening_from_token_a", + "tokenAAmount": "2000", + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect("token-A edit must prepare"); + assert_eq!(from_a["tokenAAmount"], "2000"); + assert_eq!(from_a["tokenBAmount"], "4000"); + assert_eq!(from_a["actualPriceQ64_64"], desired_price.to_string()); + + let from_b = quote_json(json!({ + "operation": "prepare_opening_from_token_b", + "tokenBAmount": "4000", + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect("token-B edit must prepare"); + assert_eq!(from_b["tokenAAmount"], "2000"); + assert_eq!(from_b["tokenBAmount"], "4000"); + + let above_javascript_integer_range = 1_u128 << 80; + let paired = above_javascript_integer_range + .checked_mul(2) + .expect("test pair fits"); + let explicit = quote_json(json!({ + "operation": "validate_explicit_opening_pair", + "tokenAAmount": above_javascript_integer_range.to_string(), + "tokenBAmount": paired.to_string(), + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect("explicit pair must validate"); + assert_eq!( + explicit["tokenAAmount"], + above_javascript_integer_range.to_string() + ); + assert_eq!(explicit["tokenBAmount"], paired.to_string()); +} + +#[test] +fn caller_opening_intents_map_reversed_order_without_local_price_math() { + let ids = PairIds::new(); + let desired_price = Q64_64_ONE.checked_mul(2).expect("test price fits"); + let request = |intent: Value| { + json!({ + "operation": "prepare_caller_opening_pair", + "firstTokenDefinitionId": ids.first_token_id.to_string(), + "secondTokenDefinitionId": ids.second_token_id.to_string(), + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + "intent": intent, + }) + }; + + let first = quote_json(request(json!({ + "kind": "first_amount", + "amount": "4000", + }))) + .expect("caller first amount must prepare"); + assert_eq!(first["callerOrder"], "reversed"); + assert_eq!(first["firstAmount"], "4000"); + assert_eq!(first["secondAmount"], "2000"); + assert_eq!(first["stored"]["tokenAAmount"], "2000"); + assert_eq!(first["stored"]["tokenBAmount"], "4000"); + + let second = quote_json(request(json!({ + "kind": "second_amount", + "amount": "2000", + }))) + .expect("caller second amount must prepare"); + assert_eq!(second["firstAmount"], "4000"); + assert_eq!(second["secondAmount"], "2000"); + + let explicit = quote_json(request(json!({ + "kind": "explicit", + "firstAmount": "4000", + "secondAmount": "2000", + }))) + .expect("caller explicit amounts must prepare"); + assert_eq!( + explicit["stored"]["actualPriceQ64_64"], + desired_price.to_string() + ); + + let minimum = quote_json(request(json!({ "kind": "minimum" }))) + .expect("caller minimum amounts must prepare"); + assert_eq!(minimum["callerOrder"], "reversed"); + assert_decimal_string(&minimum["firstAmount"]); + assert_decimal_string(&minimum["secondAmount"]); +} + +#[test] +fn opening_intent_wire_errors_keep_stable_codes_and_string_inputs() { + let zero_price = quote_json(json!({ + "operation": "prepare_minimum_opening_pair", + "desiredPriceQ64_64": "0", + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect_err("zero desired price must fail"); + assert_eq!(zero_price.code(), "zero_desired_price"); + + let numeric_amount = quote_json(json!({ + "operation": "prepare_opening_from_token_a", + "tokenAAmount": 2000, + "desiredPriceQ64_64": Q64_64_ONE.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect_err("numeric chain amount must not enter the lossless wire contract"); + assert_eq!(numeric_amount.code(), "invalid_request"); + + let mismatched = quote_json(json!({ + "operation": "validate_explicit_opening_pair", + "tokenAAmount": "2000", + "tokenBAmount": "4001", + "desiredPriceQ64_64": (Q64_64_ONE * 2).to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect_err("nonmatching explicit spot price must fail"); + assert_eq!(mismatched.code(), "spot_price_mismatch"); +} diff --git a/programs/amm/client/tests/wire_prepare_contract.rs b/programs/amm/client/tests/wire_prepare_contract.rs index ffd7a659..a8ba37be 100644 --- a/programs/amm/client/tests/wire_prepare_contract.rs +++ b/programs/amm/client/tests/wire_prepare_contract.rs @@ -184,7 +184,7 @@ fn prepare_wire_operations_return_lossless_instruction_args() { minimum_guard_amount(decimal(&add["quote"]["liquidityToMint"]), tolerance) .expect("minimum LP guard must fit") ); - assert_eq!(add["instructionArgs"]["maxAmountToAddTokenA"], "200"); + assert_eq!(add["instructionArgs"]["maxAmountToAddTokenA"], "400"); assert_eq!(add["instructionArgs"]["maxAmountToAddTokenB"], "100"); let mut remove_request = fixture.request("prepare_remove_liquidity"); diff --git a/programs/amm/client/tests/wire_transaction_contract.rs b/programs/amm/client/tests/wire_transaction_contract.rs new file mode 100644 index 00000000..2359f84a --- /dev/null +++ b/programs/amm/client/tests/wire_transaction_contract.rs @@ -0,0 +1,565 @@ +use amm_client::wire::{plan_json, quote_json}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, +}; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; +const LARGE: u128 = 9_007_199_254_740_993; +const DEADLINE: u64 = 9_007_199_254_740_993; + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn definition(total_supply: u128, authority: Option) -> Account { + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ) +} + +fn holding(definition_id: AccountId, balance: u128) -> Account { + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ) +} + +fn snapshot(id: AccountId, account: &Account) -> Value { + json!({ + "id": id.to_string(), + "programOwner": account.program_owner, + "balance": account.balance.to_string(), + "nonce": account.nonce.0.to_string(), + "data": account + .data + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + }) +} + +struct TransactionFixture { + first_token_id: AccountId, + second_token_id: AccountId, + pool_id: AccountId, + first_vault_id: AccountId, + second_vault_id: AccountId, + liquidity_definition_id: AccountId, + lp_lock_holding_id: AccountId, + current_tick_id: AccountId, + first_holding_id: AccountId, + second_holding_id: AccountId, + liquidity_holding_id: AccountId, + fresh_liquidity_holding_id: AccountId, + config: Value, + active_snapshots: Value, + missing_snapshots: Value, + first_holding: Value, + second_holding: Value, + liquidity_holding: Value, + fresh_liquidity_holding: Value, +} + +impl TransactionFixture { + fn new() -> Self { + let first_token_id = AccountId::new([1; 32]); + let second_token_id = AccountId::new([2; 32]); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, second_token_id, first_token_id); + let first_vault_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, first_token_id); + let second_vault_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, second_token_id); + let liquidity_definition_id = compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id); + let lp_lock_holding_id = compute_lp_lock_holding_pda(AMM_PROGRAM_ID, pool_id); + let current_tick_id = compute_current_tick_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id); + let first_holding_id = AccountId::new([20; 32]); + let second_holding_id = AccountId::new([21; 32]); + let liquidity_holding_id = AccountId::new([22; 32]); + let fresh_liquidity_holding_id = AccountId::new([30; 32]); + let total_supply = LARGE.checked_mul(10).expect("test supply fits"); + let config = snapshot( + compute_config_pda(AMM_PROGRAM_ID), + &account( + AMM_PROGRAM_ID, + Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }), + ), + ); + let clock_bytes = ClockAccountData { + block_id: 123, + timestamp: 456, + } + .to_bytes(); + let clock = snapshot( + CLOCK_01_PROGRAM_ACCOUNT_ID, + &account( + [88; 8], + Data::try_from(clock_bytes).expect("clock data must fit"), + ), + ); + let first_definition = snapshot(first_token_id, &definition(total_supply, None)); + let second_definition = snapshot(second_token_id, &definition(total_supply, None)); + + let pool = PoolDefinition { + definition_token_a_id: second_token_id, + definition_token_b_id: first_token_id, + vault_a_id: second_vault_id, + vault_b_id: first_vault_id, + liquidity_pool_id: liquidity_definition_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + let active_snapshots = json!({ + "pool": snapshot(pool_id, &account(AMM_PROGRAM_ID, Data::from(&pool))), + "firstTokenDefinition": first_definition.clone(), + "secondTokenDefinition": second_definition.clone(), + "firstTokenVault": snapshot(first_vault_id, &holding(first_token_id, 550)), + "secondTokenVault": snapshot(second_vault_id, &holding(second_token_id, 1_100)), + "liquidityDefinition": snapshot( + liquidity_definition_id, + &definition(2_000, Some(liquidity_definition_id)), + ), + "lpLockHolding": snapshot( + lp_lock_holding_id, + &holding(liquidity_definition_id, MINIMUM_LIQUIDITY), + ), + "currentTick": snapshot( + current_tick_id, + &account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 400, + }), + ), + ), + "clock": clock.clone(), + }); + let missing_snapshots = json!({ + "pool": snapshot(pool_id, &Account::default()), + "firstTokenDefinition": first_definition, + "secondTokenDefinition": second_definition, + "firstTokenVault": snapshot(first_vault_id, &Account::default()), + "secondTokenVault": snapshot(second_vault_id, &Account::default()), + "liquidityDefinition": snapshot(liquidity_definition_id, &Account::default()), + "lpLockHolding": snapshot(lp_lock_holding_id, &Account::default()), + "currentTick": snapshot(current_tick_id, &Account::default()), + "clock": clock, + }); + let holding_balance = LARGE.checked_mul(3).expect("test balance fits"); + + Self { + first_token_id, + second_token_id, + pool_id, + first_vault_id, + second_vault_id, + liquidity_definition_id, + lp_lock_holding_id, + current_tick_id, + first_holding_id, + second_holding_id, + liquidity_holding_id, + fresh_liquidity_holding_id, + config, + active_snapshots, + missing_snapshots, + first_holding: snapshot(first_holding_id, &holding(first_token_id, holding_balance)), + second_holding: snapshot( + second_holding_id, + &holding(second_token_id, holding_balance), + ), + liquidity_holding: snapshot( + liquidity_holding_id, + &holding(liquidity_definition_id, 1_000), + ), + fresh_liquidity_holding: snapshot(fresh_liquidity_holding_id, &Account::default()), + } + } + + fn active_common(&self, operation: &str) -> Value { + json!({ + "operation": operation, + "ammProgramId": AMM_PROGRAM_ID, + "config": self.config.clone(), + "snapshots": self.active_snapshots.clone(), + "firstTokenDefinitionId": self.first_token_id.to_string(), + "secondTokenDefinitionId": self.second_token_id.to_string(), + "firstTokenHolding": self.first_holding.clone(), + "secondTokenHolding": self.second_holding.clone(), + "liquidityHolding": self.liquidity_holding.clone(), + "slippageBps": "100", + "expectedFeeBps": FEE_TIER_BPS_30.to_string(), + "deadline": DEADLINE.to_string(), + }) + } + + fn swap_common(&self, operation: &str) -> Value { + json!({ + "operation": operation, + "ammProgramId": AMM_PROGRAM_ID, + "config": self.config.clone(), + "snapshots": self.active_snapshots.clone(), + "inputTokenDefinitionId": self.first_token_id.to_string(), + "outputTokenDefinitionId": self.second_token_id.to_string(), + "inputHolding": self.first_holding.clone(), + "outputHolding": self.second_holding.clone(), + "slippageBps": "100", + "expectedFeeBps": FEE_TIER_BPS_30.to_string(), + "deadline": DEADLINE.to_string(), + }) + } +} + +fn insert(value: &mut Value, field: &str, inserted: Value) { + drop( + value + .as_object_mut() + .expect("request must be an object") + .insert(String::from(field), inserted), + ); +} + +fn decode_instruction(response: &Value) -> Instruction { + let words = response + .pointer("/plan/instructionWords") + .expect("plan must contain instruction words") + .clone(); + let words: Vec = + serde_json::from_value(words).expect("plan instruction words must be u32 JSON values"); + risc0_zkvm::serde::from_slice(&words).expect("guest codec must decode wire plan") +} + +fn assert_instruction_arg(response: &Value, name: &str, expected: impl ToString) { + let pointer = format!("/plan/instructionArgs/{name}"); + assert_eq!( + response + .pointer(&pointer) + .and_then(Value::as_str) + .expect("typed instruction argument must be a string"), + expected.to_string() + ); +} + +fn assert_common_contract(response: &Value, operation: &str, expect_spot_change: bool) { + assert_eq!(response["operation"], operation); + assert_eq!(response["deadline"], DEADLINE.to_string()); + assert!(response["quote"].is_object()); + assert!(response + .pointer("/callerAmounts/first") + .is_some_and(Value::is_string)); + assert!(response + .pointer("/callerAmounts/second") + .is_some_and(Value::is_string)); + assert!(response + .pointer("/plan/accounts") + .is_some_and(Value::is_array)); + assert!(response + .pointer("/plan/instructionArgs") + .is_some_and(Value::is_object)); + assert_eq!( + response["affectedAccountIds"], + *response + .pointer("/plan/affectedAccountIds") + .expect("plan must contain affected account IDs") + ); + assert!(response + .pointer("/walletPrerequisites/signerAccountIds") + .is_some_and(Value::is_array)); + assert!(response + .pointer("/walletPrerequisites/freshAccountIds") + .is_some_and(Value::is_array)); + assert!(response + .pointer("/walletPrerequisites/funding") + .is_some_and(Value::is_array)); + + let commitment = response["quoteCommitment"] + .as_str() + .expect("commitment must be a hex string"); + assert_eq!(commitment.len(), 64); + assert!(commitment + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + assert_eq!( + response["poolSpotChangeBps"].is_string(), + expect_spot_change + ); + assert_eq!(response["poolSpotChangeBps"].is_null(), !expect_spot_change); +} + +#[test] +fn five_transaction_operations_emit_exact_plans_and_task_artifacts() { + let fixture = TransactionFixture::new(); + + let second_amount = LARGE.checked_mul(2).expect("test amount fits"); + let create = quote_json(json!({ + "operation": "prepare_create_pool_transaction", + "ammProgramId": AMM_PROGRAM_ID, + "config": fixture.config.clone(), + "snapshots": fixture.missing_snapshots.clone(), + "firstTokenDefinitionId": fixture.first_token_id.to_string(), + "secondTokenDefinitionId": fixture.second_token_id.to_string(), + "firstTokenHolding": fixture.first_holding.clone(), + "secondTokenHolding": fixture.second_holding.clone(), + "liquidityHolding": fixture.fresh_liquidity_holding.clone(), + "firstAmount": LARGE.to_string(), + "secondAmount": second_amount.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + "deadline": DEADLINE.to_string(), + })) + .expect("create transaction must prepare"); + assert_common_contract(&create, "create_pool", false); + assert_eq!(create["callerAmounts"]["first"], LARGE.to_string()); + assert_eq!(create["callerAmounts"]["second"], second_amount.to_string()); + assert_eq!( + create["walletPrerequisites"]["freshAccountIds"], + json!([fixture.fresh_liquidity_holding_id.to_string()]) + ); + assert_eq!( + create["walletPrerequisites"]["funding"][0]["required"], + LARGE.to_string() + ); + match decode_instruction(&create) { + Instruction::NewDefinition { + token_a_amount, + token_b_amount, + deadline, + .. + } => { + assert_eq!(token_a_amount, second_amount); + assert_eq!(token_b_amount, LARGE); + assert_eq!(deadline, DEADLINE); + assert_instruction_arg(&create, "tokenAAmount", token_a_amount); + assert_instruction_arg(&create, "tokenBAmount", token_b_amount); + assert_instruction_arg(&create, "fees", FEE_TIER_BPS_30); + assert_instruction_arg(&create, "deadline", deadline); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::AddLiquidity { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => { + panic!("create wire operation emitted wrong instruction") + } + } + + let mut add_request = fixture.active_common("prepare_add_liquidity_transaction"); + insert(&mut add_request, "maxFirstAmount", json!("100")); + insert(&mut add_request, "maxSecondAmount", json!("400")); + let add = quote_json(add_request.clone()).expect("add transaction must prepare"); + assert_eq!( + plan_json(add_request).expect("plan entrypoint must prepare task transactions"), + add + ); + assert_common_contract(&add, "add_liquidity", false); + assert_eq!(add["callerAmounts"]["first"], "100"); + assert_eq!(add["callerAmounts"]["second"], "200"); + assert_eq!( + add.pointer("/walletPrerequisites/funding") + .and_then(Value::as_array) + .map(Vec::len), + Some(2) + ); + assert_eq!( + add.pointer("/walletPrerequisites/funding/0/required"), + Some(&json!("100")) + ); + assert_eq!( + add.pointer("/walletPrerequisites/funding/1/required"), + Some(&json!("400")) + ); + match decode_instruction(&add) { + Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + deadline, + } => { + assert_eq!(max_amount_to_add_token_a, 400); + assert_eq!(max_amount_to_add_token_b, 100); + assert_instruction_arg(&add, "minAmountLiquidity", min_amount_liquidity); + assert_instruction_arg(&add, "maxAmountToAddTokenA", max_amount_to_add_token_a); + assert_instruction_arg(&add, "maxAmountToAddTokenB", max_amount_to_add_token_b); + assert_instruction_arg(&add, "deadline", deadline); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => panic!("add wire operation emitted wrong instruction"), + } + + let mut remove_request = fixture.active_common("prepare_remove_liquidity_transaction"); + insert(&mut remove_request, "removeLiquidityAmount", json!("500")); + let remove = quote_json(remove_request).expect("remove transaction must prepare"); + assert_common_contract(&remove, "remove_liquidity", false); + assert_eq!(remove["callerAmounts"]["first"], "125"); + assert_eq!(remove["callerAmounts"]["second"], "250"); + assert_eq!( + remove["walletPrerequisites"]["funding"][0]["holdingAccountId"], + fixture.liquidity_holding_id.to_string() + ); + match decode_instruction(&remove) { + Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + deadline, + } => { + assert_instruction_arg(&remove, "removeLiquidityAmount", remove_liquidity_amount); + assert_instruction_arg( + &remove, + "minAmountToRemoveTokenA", + min_amount_to_remove_token_a, + ); + assert_instruction_arg( + &remove, + "minAmountToRemoveTokenB", + min_amount_to_remove_token_b, + ); + assert_instruction_arg(&remove, "deadline", deadline); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::AddLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => panic!("remove wire operation emitted wrong instruction"), + } + + let mut exact_input_request = fixture.swap_common("prepare_swap_exact_input_transaction"); + insert(&mut exact_input_request, "amountIn", json!("100")); + let exact_input = + quote_json(exact_input_request).expect("exact-input transaction must prepare"); + assert_common_contract(&exact_input, "swap_exact_input", true); + assert_eq!(exact_input["callerAmounts"]["first"], "100"); + assert_eq!( + exact_input["walletPrerequisites"]["funding"][0]["holdingAccountId"], + fixture.first_holding_id.to_string() + ); + match decode_instruction(&exact_input) { + Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + deadline, + } => { + assert_instruction_arg(&exact_input, "swapAmountIn", swap_amount_in); + assert_instruction_arg(&exact_input, "minAmountOut", min_amount_out); + assert_instruction_arg(&exact_input, "deadline", deadline); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::AddLiquidity { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => { + panic!("exact-input wire operation emitted wrong instruction") + } + } + + let mut exact_output_request = fixture.swap_common("prepare_swap_exact_output_transaction"); + insert(&mut exact_output_request, "exactAmountOut", json!("100")); + let exact_output = + quote_json(exact_output_request).expect("exact-output transaction must prepare"); + assert_common_contract(&exact_output, "swap_exact_output", true); + assert_eq!(exact_output["callerAmounts"]["second"], "100"); + match decode_instruction(&exact_output) { + Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + deadline, + } => { + assert_instruction_arg(&exact_output, "exactAmountOut", exact_amount_out); + assert_instruction_arg(&exact_output, "maxAmountIn", max_amount_in); + assert_instruction_arg(&exact_output, "deadline", deadline); + assert_eq!( + exact_output.pointer("/walletPrerequisites/funding/0/required"), + Some(&json!(max_amount_in.to_string())) + ); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::AddLiquidity { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SyncReserves => { + panic!("exact-output wire operation emitted wrong instruction") + } + } + + assert_eq!( + fixture.pool_id.to_string(), + add["plan"]["accounts"][1]["id"] + ); + assert_ne!(fixture.first_vault_id, fixture.second_vault_id); + assert_ne!(fixture.lp_lock_holding_id, fixture.current_tick_id); + assert_eq!( + fixture.liquidity_definition_id.to_string(), + remove["walletPrerequisites"]["funding"][0]["tokenDefinitionId"] + ); + let output_holding = exact_input["plan"]["accounts"] + .as_array() + .expect("plan accounts must be an array") + .iter() + .find(|account| account["role"] == "user_output_holding") + .expect("swap plan must contain output holding"); + assert_eq!(fixture.second_holding_id.to_string(), output_holding["id"]); +} + +#[test] +fn transaction_wire_rejects_expected_fee_mismatch() { + let fixture = TransactionFixture::new(); + let mut request = fixture.active_common("prepare_add_liquidity_transaction"); + insert(&mut request, "maxFirstAmount", json!("100")); + insert(&mut request, "maxSecondAmount", json!("400")); + insert(&mut request, "expectedFeeBps", json!("100")); + + let error = quote_json(request).expect_err("wrong expected fee must fail"); + assert_eq!(error.code(), "fee_mismatch"); +} diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 0cdd9444..39f80c5c 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -472,19 +472,36 @@ pub fn compute_pool_pda( ) } +/// Returns the deterministic token order used by the pool PDA derivation. +/// +/// The token with the lexicographically greater raw account-ID bytes is token A. This comparison +/// is independent of any textual account representation. Returns `None` when both definitions are +/// the same, because a token cannot be paired with itself. +#[must_use] +pub fn canonical_token_pair( + first_definition_id: AccountId, + second_definition_id: AccountId, +) -> Option<(AccountId, AccountId)> { + match first_definition_id + .value() + .cmp(second_definition_id.value()) + { + std::cmp::Ordering::Less => Some((second_definition_id, first_definition_id)), + std::cmp::Ordering::Greater => Some((first_definition_id, second_definition_id)), + std::cmp::Ordering::Equal => None, + } +} + pub fn compute_pool_pda_seed( definition_token_a_id: AccountId, definition_token_b_id: AccountId, ) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256}; - let (token_1, token_2) = match definition_token_a_id - .value() - .cmp(definition_token_b_id.value()) - { - std::cmp::Ordering::Less => (definition_token_b_id, definition_token_a_id), - std::cmp::Ordering::Greater => (definition_token_a_id, definition_token_b_id), - std::cmp::Ordering::Equal => panic!("Definitions match"), + let Some((token_1, token_2)) = + canonical_token_pair(definition_token_a_id, definition_token_b_id) + else { + panic!("Definitions match"); }; let mut bytes = [0; 64]; @@ -600,6 +617,43 @@ mod tests { /// `1.0` in Q64.64 is `2^64`. const ONE_Q64_64: u128 = 1u128 << 64; + fn account_id(byte: u8) -> AccountId { + AccountId::new([byte; 32]) + } + + #[test] + fn canonical_token_pair_uses_raw_descending_account_id_order() { + let lower = account_id(1); + let higher = account_id(2); + + assert_eq!(canonical_token_pair(lower, higher), Some((higher, lower))); + assert_eq!(canonical_token_pair(higher, lower), Some((higher, lower))); + assert_eq!(canonical_token_pair(lower, lower), None); + } + + #[test] + fn pool_pda_is_unchanged_by_caller_token_order() { + let amm_program_id = [42u32; 8]; + let lower = account_id(1); + let higher = account_id(2); + + assert_eq!( + compute_pool_pda(amm_program_id, lower, higher), + compute_pool_pda(amm_program_id, higher, lower) + ); + assert_eq!( + compute_pool_pda_seed(lower, higher), + compute_pool_pda_seed(higher, lower) + ); + } + + #[test] + #[should_panic(expected = "Definitions match")] + fn pool_pda_seed_preserves_identical_definition_panic() { + let definition = account_id(1); + let _ = compute_pool_pda_seed(definition, definition); + } + #[test] fn equal_reserves_map_to_unit_price() { assert_eq!(spot_price_q64_64(1_000, 1_000), ONE_Q64_64); From 424ba1af02e1c0b9bca6394fe826303931e708bb Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 18:02:51 -0300 Subject: [PATCH 4/8] fix(amm): make square root feature-independent --- programs/amm/core/src/lib.rs | 55 ++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 39f80c5c..807fd125 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -375,11 +375,33 @@ pub fn mul_div_ceil(a: u128, b: u128, c: u128) -> u128 { #[must_use] pub fn isqrt_product(a: u128, b: u128) -> u128 { use alloy_primitives::U256; + let product = U256::from(a) .checked_mul(U256::from(b)) .expect("u128 * u128 always fits in U256"); - let root = product.root(2); // ruint integer root; floor sqrt - u128::try_from(root).expect("isqrt_product result exceeds u128") + if product == U256::ZERO { + return 0; + } + + // Start at a power of two above the exact root, then converge downward. Unlike + // `Uint::root`, this uses only `ruint`'s core arithmetic and remains available when its + // host-only `std` feature is disabled for guest-compatible crates. + let mut root = U256::ONE + .checked_shl(product.bit_len().div_ceil(2)) + .expect("square-root estimate fits in U256"); + loop { + let quotient = product + .checked_div(root) + .expect("nonzero square-root estimate"); + let next = root + .checked_add(quotient) + .expect("square-root iteration fits in U256") + .wrapping_shr(1); + if next >= root { + return u128::try_from(root).expect("isqrt_product result exceeds u128"); + } + root = next; + } } impl TryFrom<&Data> for PoolDefinition { @@ -770,16 +792,27 @@ mod tests { #[test] fn isqrt_product_handles_the_1e20_times_2e20_overflow_case() { // 1e20 * 2e20 = 2e40 overflows u128 (max ~3.4e38); the U256 intermediate keeps it exact. - let a = 100_000_000_000_000_000_000u128; // 1e20 - let b = 200_000_000_000_000_000_000u128; // 2e20 - // floor(sqrt(2e40)) computed independently in U256. - let expected = { - use alloy_primitives::U256; - let product = U256::from(a).checked_mul(U256::from(b)).unwrap(); - u128::try_from(product.root(2)).unwrap() - }; - assert_eq!(isqrt_product(a, b), expected); + let a = 100_000_000_000_000_000_000u128; + let b = 200_000_000_000_000_000_000u128; // Sanity: floor(sqrt(2e40)) = floor(1.4142...e20) = 141421356237309504880. assert_eq!(isqrt_product(a, b), 141_421_356_237_309_504_880); } + + #[test] + fn isqrt_product_preserves_floor_bounds_without_ruint_std() { + use alloy_primitives::U256; + + for (a, b) in [ + (1, 2), + (5, 10), + (u128::MAX, u128::MAX - 1), + (u128::MAX, u128::MAX), + ] { + let product = U256::from(a) * U256::from(b); + let root = U256::from(isqrt_product(a, b)); + assert!(root * root <= product); + let next = root + U256::ONE; + assert!(next.checked_mul(next).is_none_or(|square| square > product)); + } + } } From 04c4d37fcfb9ae37aa91852d2fea1b187b61fdbf Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 18:14:32 -0300 Subject: [PATCH 5/8] feat(amm-client): freeze wire contract --- programs/amm/client/README.md | 12 +- programs/amm/client/docs/wire-api.md | 41 +- programs/amm/client/include/amm_client.h | 12 +- programs/amm/client/src/wire.rs | 625 ++++++++++-------- programs/amm/client/tests/common/mod.rs | 11 + programs/amm/client/tests/ffi_contract.rs | 47 +- .../tests/wire_discovery_intent_contract.rs | 22 +- .../amm/client/tests/wire_prepare_contract.rs | 9 +- .../client/tests/wire_transaction_contract.rs | 31 +- 9 files changed, 452 insertions(+), 358 deletions(-) create mode 100644 programs/amm/client/tests/common/mod.rs diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md index e5b19580..b6593aea 100644 --- a/programs/amm/client/README.md +++ b/programs/amm/client/README.md @@ -71,9 +71,9 @@ Every call returns an owned JSON envelope. Release it exactly once with `amm_cli `NULL` to the free function is allowed. See [`include/amm_client.h`](include/amm_client.h) and [`docs/wire-api.md`](docs/wire-api.md) for the complete transport contract. -Raw `u128` and `u64` values cross JSON as decimal strings. Account IDs use their canonical base58 -display form, program IDs use eight JSON `u32` words, account data uses hexadecimal, and encoded -instruction words remain JSON `u32` numbers. No JavaScript `Number` conversion is required for -chain amounts or deadlines. Plan JSON also includes typed `instructionArgs`, derived directly from -the same `amm_core::Instruction` encoded in `instructionWords`. Both C entrypoints accept the five -snapshot-bound `prepare_*_transaction` operations. +Raw `u128` and `u64` values cross JSON as decimal strings. Account IDs use canonical base58. +Program IDs use 64-character lowercase hexadecimal strings. Account data uses hexadecimal, and +encoded instruction words remain JSON `u32` numbers. No JavaScript `Number` conversion is required +for chain amounts or deadlines. Plan JSON also includes typed `instructionArgs`, derived directly +from the same `amm_core::Instruction` encoded in `instructionWords`. Only `amm_client_plan` accepts +the five snapshot-bound `prepare_*_transaction` operations. diff --git a/programs/amm/client/docs/wire-api.md b/programs/amm/client/docs/wire-api.md index 8eef6697..b533215a 100644 --- a/programs/amm/client/docs/wire-api.md +++ b/programs/amm/client/docs/wire-api.md @@ -14,9 +14,10 @@ Requests may include `"schema":"amm-client.v1"`. Schema-less requests remain acc compatibility. Every successful wire value and every C envelope identifies the response schema. All `u128` amounts, reserves, supplies, fees, nonces, and balances are unsigned decimal strings. -All `u64` windows and deadlines are also decimal strings. Program IDs are arrays of eight `u32` -words. Signed ticks are decimal strings. Account IDs are base58 strings. Account `data` is an -even-length hexadecimal string. +All `u64` windows and deadlines are also decimal strings. Program IDs are exactly 64 lowercase +hexadecimal characters: the 32 bytes formed by concatenating the eight `u32` words in +little-endian byte order. Signed ticks are decimal strings. Account IDs use canonical base58. +Account `data` is an even-length hexadecimal string. ## Shared inputs @@ -24,9 +25,9 @@ Plan context: ```json { - "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], - "tokenProgramId": [0, 0, 0, 0, 0, 0, 0, 0], - "twapOracleProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "ammProgramId": "0000000000000000000000000000000000000000000000000000000000000000", + "tokenProgramId": "0000000000000000000000000000000000000000000000000000000000000000", + "twapOracleProgramId": "0000000000000000000000000000000000000000000000000000000000000000", "authority": "base58-account-id" } ``` @@ -53,7 +54,7 @@ Fetched account snapshot used by quotes: ```json { "id": "base58-account-id", - "programOwner": [0, 0, 0, 0, 0, 0, 0, 0], + "programOwner": "0000000000000000000000000000000000000000000000000000000000000000", "balance": "0", "nonce": "0", "data": "00ff" @@ -64,7 +65,7 @@ Existing-pool quote operations include these top-level state fields: ```json { - "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "ammProgramId": "0000000000000000000000000000000000000000000000000000000000000000", "config": { "...": "account snapshot" }, "snapshot": { "pool": { "...": "account snapshot" }, @@ -128,7 +129,7 @@ A successful plan value contains the following fields (`instructionWords` is abb "maxAmountToAddTokenB": "100", "deadline": "1900000000000" }, - "programId": [0, 0, 0, 0, 0, 0, 0, 0], + "programId": "0000000000000000000000000000000000000000000000000000000000000000", "accounts": [ { "id": "base58-account-id", @@ -152,8 +153,8 @@ strings. Account rows follow guest/IDL order. ## Quote operations Send requests to `amm_client_quote` or `wire::quote_json`. Pool economic operations use the -existing-pool quote state described above. Discovery, opening intent, and task-transaction -operations use the fields shown in this table and the sections below. +existing-pool quote state described above. Discovery and opening-intent operations use the fields +shown in this table and the sections below. | `operation` | Additional fields | |---|---| @@ -185,11 +186,6 @@ operations use the fields shown in this table and the sections below. | `swap_exact_output` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `exactAmountOut`, `maximumAmountIn` | | `sync_reserves` | no additional fields | | `create_oracle_price_account` | `windowDuration` | -| `prepare_create_pool_transaction` | task-transaction fields below | -| `prepare_add_liquidity_transaction` | task-transaction fields below | -| `prepare_remove_liquidity_transaction` | task-transaction fields below | -| `prepare_swap_exact_input_transaction` | task-transaction fields below | -| `prepare_swap_exact_output_transaction` | task-transaction fields below | Quote values use these result shapes: @@ -234,9 +230,10 @@ amounts. ## Task transactions -The five snapshot-bound task operations are accepted by both `amm_client_plan`/`wire::plan_json` -and `amm_client_quote`/`wire::quote_json`. Every request includes `ammProgramId`, raw `config`, the -complete caller-ordered `snapshots`, and decimal-string `deadline`. +The five snapshot-bound task operations are accepted only by +`amm_client_plan`/`wire::plan_json`. Every request includes `ammProgramId`, raw `config`, the +complete caller-ordered `snapshots`, and decimal-string `deadline`. The quote endpoint rejects +these operation tags with `invalid_request`. | `operation` | Additional fields | |---|---| @@ -314,5 +311,11 @@ The client validates account decoding, configured owners, canonical PDAs, pool/v relationships, swap input/output pairing, and required input balances. Quote arithmetic failures retain the stable `amm_program::quote::QuoteError` code. +Every failure uses `{ "code": "...", "message": "..." }`. `code` is the stable +machine-readable contract; `message` is diagnostic text. JSON adapter failures return +`invalid_request` or `unsupported_schema`. The C envelope additionally returns `null_request`, +`invalid_utf8`, `invalid_json`, `response_serialization_failed`, or `response_contains_nul` for +boundary failures. + No request performs network I/O or checks an ImageID, release version, compatibility manifest, or program allowlist. Deployment configuration is expected to select the corresponding AMM build. diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h index 4dc680a0..54446442 100644 --- a/programs/amm/client/include/amm_client.h +++ b/programs/amm/client/include/amm_client.h @@ -19,18 +19,18 @@ char *amm_client_plan(const char *request_json); * Accepts a tagged UTF-8 JSON request and returns an owned UTF-8 JSON envelope. * Supported operation tags include protocol constants; config and pair discovery; * pair inspection; caller-order opening preparation; economic quote/preparation - * operations; and prepare_create_pool_transaction, - * prepare_add_liquidity_transaction, prepare_remove_liquidity_transaction, - * prepare_swap_exact_input_transaction, and - * prepare_swap_exact_output_transaction. See docs/wire-api.md for fields. + * operations; reserve synchronization; and oracle-price initialization. + * Snapshot-bound prepare_*_transaction operations belong to amm_client_plan. + * See docs/wire-api.md for fields. * Release the result with amm_client_free. */ char *amm_client_quote(const char *request_json); /* * Raw u128, u64, and signed tick values are decimal JSON strings. Program IDs - * and instruction words are JSON u32 arrays. Account IDs are base58 strings and - * account data is hexadecimal. Requests may carry schema "amm-client.v1"; + * are 64-character lowercase hexadecimal strings. Instruction words are JSON + * u32 arrays. Account IDs use canonical base58 and account data is hexadecimal. + * Requests may carry schema "amm-client.v1"; * schema-less legacy requests remain accepted. Responses use * {"schema":"amm-client.v1","ok":true,"value":...} or the same envelope * with ok=false and error={"code":...,"message":...}. Plan values contain diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs index ab9625d8..fa545b03 100644 --- a/programs/amm/client/src/wire.rs +++ b/programs/amm/client/src/wire.rs @@ -90,24 +90,42 @@ impl From for WireError { } } +#[derive(Clone, Copy, Deserialize)] +#[serde(try_from = "String")] +struct ProgramIdInput(ProgramId); + +impl From for ProgramId { + fn from(value: ProgramIdInput) -> Self { + value.0 + } +} + +impl TryFrom for ProgramIdInput { + type Error = String; + + fn try_from(value: String) -> Result { + parse_program_id(&value).map(Self) + } +} + #[derive(Deserialize)] #[serde(tag = "operation", rename_all = "snake_case")] enum PlanRequest { Initialize { #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, + amm_program_id: ProgramIdInput, #[serde(rename = "tokenProgramId")] - token_program_id: ProgramId, + token_program_id: ProgramIdInput, #[serde(rename = "twapOracleProgramId")] - twap_oracle_program_id: ProgramId, + twap_oracle_program_id: ProgramIdInput, authority: String, }, UpdateConfig { context: ContextInput, #[serde(rename = "tokenProgramId")] - token_program_id: Option, + token_program_id: Option, #[serde(rename = "twapOracleProgramId")] - twap_oracle_program_id: Option, + twap_oracle_program_id: Option, #[serde(rename = "newAuthority")] new_authority: Option, }, @@ -208,125 +226,9 @@ enum PlanRequest { context: ContextInput, pool: PoolInput, }, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ContextInput { - amm_program_id: ProgramId, - token_program_id: ProgramId, - twap_oracle_program_id: ProgramId, - authority: String, -} - -impl ContextInput { - fn into_context(self) -> Result { - Ok(AmmContext::new( - self.amm_program_id, - AmmConfig { - token_program_id: self.token_program_id, - twap_oracle_program_id: self.twap_oracle_program_id, - authority: account_id(&self.authority, "context.authority")?, - }, - )) - } -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PoolInput { - pool_id: String, - definition_token_a_id: String, - definition_token_b_id: String, - vault_a_id: String, - vault_b_id: String, - liquidity_pool_id: String, - liquidity_pool_supply: String, - reserve_a: String, - reserve_b: String, - fees: String, -} - -impl PoolInput { - fn into_pool(self) -> Result<(AccountId, PoolDefinition), WireError> { - Ok(( - account_id(&self.pool_id, "pool.poolId")?, - PoolDefinition { - definition_token_a_id: account_id( - &self.definition_token_a_id, - "pool.definitionTokenAId", - )?, - definition_token_b_id: account_id( - &self.definition_token_b_id, - "pool.definitionTokenBId", - )?, - vault_a_id: account_id(&self.vault_a_id, "pool.vaultAId")?, - vault_b_id: account_id(&self.vault_b_id, "pool.vaultBId")?, - liquidity_pool_id: account_id(&self.liquidity_pool_id, "pool.liquidityPoolId")?, - liquidity_pool_supply: decimal_u128( - &self.liquidity_pool_supply, - "pool.liquidityPoolSupply", - )?, - reserve_a: decimal_u128(&self.reserve_a, "pool.reserveA")?, - reserve_b: decimal_u128(&self.reserve_b, "pool.reserveB")?, - fees: decimal_u128(&self.fees, "pool.fees")?, - }, - )) - } -} - -#[derive(Deserialize)] -#[serde(tag = "operation", rename_all = "snake_case")] -enum QuoteRequest { - ProtocolConstants, - DeriveConfigId { - #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, - }, - InspectConfig { - #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, - config: AccountSnapshotInput, - }, - CanonicalPair { - #[serde(rename = "firstTokenDefinitionId")] - first_token_definition_id: String, - #[serde(rename = "secondTokenDefinitionId")] - second_token_definition_id: String, - }, - DerivePairReadManifest { - #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, - config: AccountSnapshotInput, - #[serde(rename = "firstTokenDefinitionId")] - first_token_definition_id: String, - #[serde(rename = "secondTokenDefinitionId")] - second_token_definition_id: String, - }, - InspectPair { - #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, - config: AccountSnapshotInput, - #[serde(rename = "firstTokenDefinitionId")] - first_token_definition_id: String, - #[serde(rename = "secondTokenDefinitionId")] - second_token_definition_id: String, - snapshots: PairReadSnapshotsInput, - }, - PrepareCallerOpeningPair { - #[serde(rename = "firstTokenDefinitionId")] - first_token_definition_id: String, - #[serde(rename = "secondTokenDefinitionId")] - second_token_definition_id: String, - #[serde(rename = "desiredPriceQ64_64")] - desired_price_q64_64: String, - #[serde(rename = "feeBps")] - fee_bps: String, - intent: OpeningLiquidityIntentInput, - }, PrepareCreatePoolTransaction { #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, + amm_program_id: ProgramIdInput, config: AccountSnapshotInput, snapshots: Box, #[serde(rename = "firstTokenDefinitionId")] @@ -349,7 +251,7 @@ enum QuoteRequest { }, PrepareAddLiquidityTransaction { #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, + amm_program_id: ProgramIdInput, config: AccountSnapshotInput, snapshots: Box, #[serde(rename = "firstTokenDefinitionId")] @@ -374,7 +276,7 @@ enum QuoteRequest { }, PrepareRemoveLiquidityTransaction { #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, + amm_program_id: ProgramIdInput, config: AccountSnapshotInput, snapshots: Box, #[serde(rename = "firstTokenDefinitionId")] @@ -397,7 +299,7 @@ enum QuoteRequest { }, PrepareSwapExactInputTransaction { #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, + amm_program_id: ProgramIdInput, config: AccountSnapshotInput, snapshots: Box, #[serde(rename = "inputTokenDefinitionId")] @@ -418,7 +320,7 @@ enum QuoteRequest { }, PrepareSwapExactOutputTransaction { #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, + amm_program_id: ProgramIdInput, config: AccountSnapshotInput, snapshots: Box, #[serde(rename = "inputTokenDefinitionId")] @@ -437,6 +339,122 @@ enum QuoteRequest { expected_fee_bps: Option, deadline: String, }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ContextInput { + amm_program_id: ProgramIdInput, + token_program_id: ProgramIdInput, + twap_oracle_program_id: ProgramIdInput, + authority: String, +} + +impl ContextInput { + fn into_context(self) -> Result { + Ok(AmmContext::new( + self.amm_program_id.into(), + AmmConfig { + token_program_id: self.token_program_id.into(), + twap_oracle_program_id: self.twap_oracle_program_id.into(), + authority: account_id(&self.authority, "context.authority")?, + }, + )) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PoolInput { + pool_id: String, + definition_token_a_id: String, + definition_token_b_id: String, + vault_a_id: String, + vault_b_id: String, + liquidity_pool_id: String, + liquidity_pool_supply: String, + reserve_a: String, + reserve_b: String, + fees: String, +} + +impl PoolInput { + fn into_pool(self) -> Result<(AccountId, PoolDefinition), WireError> { + Ok(( + account_id(&self.pool_id, "pool.poolId")?, + PoolDefinition { + definition_token_a_id: account_id( + &self.definition_token_a_id, + "pool.definitionTokenAId", + )?, + definition_token_b_id: account_id( + &self.definition_token_b_id, + "pool.definitionTokenBId", + )?, + vault_a_id: account_id(&self.vault_a_id, "pool.vaultAId")?, + vault_b_id: account_id(&self.vault_b_id, "pool.vaultBId")?, + liquidity_pool_id: account_id(&self.liquidity_pool_id, "pool.liquidityPoolId")?, + liquidity_pool_supply: decimal_u128( + &self.liquidity_pool_supply, + "pool.liquidityPoolSupply", + )?, + reserve_a: decimal_u128(&self.reserve_a, "pool.reserveA")?, + reserve_b: decimal_u128(&self.reserve_b, "pool.reserveB")?, + fees: decimal_u128(&self.fees, "pool.fees")?, + }, + )) + } +} + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum QuoteRequest { + ProtocolConstants, + DeriveConfigId { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramIdInput, + }, + InspectConfig { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramIdInput, + config: AccountSnapshotInput, + }, + CanonicalPair { + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + }, + DerivePairReadManifest { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramIdInput, + config: AccountSnapshotInput, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + }, + InspectPair { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramIdInput, + config: AccountSnapshotInput, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + snapshots: PairReadSnapshotsInput, + }, + PrepareCallerOpeningPair { + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + intent: OpeningLiquidityIntentInput, + }, PrepareMinimumOpeningPair { #[serde(rename = "desiredPriceQ64_64")] desired_price_q64_64: String, @@ -479,7 +497,7 @@ enum QuoteRequest { }, CreatePool { #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, + amm_program_id: ProgramIdInput, config: AccountSnapshotInput, #[serde(rename = "tokenADefinition")] token_a_definition: AccountSnapshotInput, @@ -494,7 +512,7 @@ enum QuoteRequest { }, PrepareCreatePool { #[serde(rename = "ammProgramId")] - amm_program_id: ProgramId, + amm_program_id: ProgramIdInput, config: AccountSnapshotInput, #[serde(rename = "tokenADefinition")] token_a_definition: AccountSnapshotInput, @@ -660,7 +678,7 @@ enum QuoteRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PoolStateInput { - amm_program_id: ProgramId, + amm_program_id: ProgramIdInput, config: AccountSnapshotInput, snapshot: PoolSnapshotInput, } @@ -668,7 +686,7 @@ struct PoolStateInput { impl PoolStateInput { fn validate(self) -> Result<(AmmContext, ValidatedPoolSnapshot), WireError> { let config = self.config.into_snapshot()?; - let context = AmmContext::from_config_account(self.amm_program_id, &config)?; + let context = AmmContext::from_config_account(self.amm_program_id.into(), &config)?; let snapshot = self.snapshot.validate(&context)?; Ok((context, snapshot)) } @@ -807,7 +825,7 @@ impl PoolSnapshotInput { #[serde(rename_all = "camelCase")] struct AccountSnapshotInput { id: String, - program_owner: ProgramId, + program_owner: ProgramIdInput, balance: String, nonce: String, data: String, @@ -821,7 +839,7 @@ impl AccountSnapshotInput { Ok(AccountSnapshot::new( account_id(&self.id, "account.id")?, Account { - program_owner: self.program_owner, + program_owner: self.program_owner.into(), balance: decimal_u128(&self.balance, "account.balance")?, data, nonce: Nonce(decimal_u128(&self.nonce, "account.nonce")?), @@ -833,27 +851,20 @@ impl AccountSnapshotInput { /// Builds a canonical low-level plan or prepares a snapshot-bound task transaction from JSON. pub fn plan_json(value: Value) -> Result { validate_wire_schema(&value)?; - if value - .get("operation") - .and_then(Value::as_str) - .is_some_and(is_prepared_transaction_operation) - { - return quote_json(value); - } let request: PlanRequest = serde_json::from_value(value) .map_err(|error| invalid_request(format!("invalid plan request: {error}")))?; - let plan = match request { + versioned(match request { PlanRequest::Initialize { amm_program_id, token_program_id, twap_oracle_program_id, authority, - } => plan_initialize(InitializePlanInput { - amm_program_id, - token_program_id, - twap_oracle_program_id, + } => transaction_plan_json(&plan_initialize(InitializePlanInput { + amm_program_id: amm_program_id.into(), + token_program_id: token_program_id.into(), + twap_oracle_program_id: twap_oracle_program_id.into(), authority: account_id(&authority, "authority")?, - }), + })), PlanRequest::UpdateConfig { context, token_program_id, @@ -865,12 +876,12 @@ pub fn plan_json(value: Value) -> Result { .as_deref() .map(|value| account_id(value, "newAuthority")) .transpose()?; - plan_update_config(UpdateConfigPlanInput { + transaction_plan_json(&plan_update_config(UpdateConfigPlanInput { context: &context, - token_program_id, - twap_oracle_program_id, + token_program_id: token_program_id.map(Into::into), + twap_oracle_program_id: twap_oracle_program_id.map(Into::into), new_authority, - }) + })) } PlanRequest::CreatePriceObservations { context, @@ -878,11 +889,13 @@ pub fn plan_json(value: Value) -> Result { window_duration, } => { let context = context.into_context()?; - plan_create_price_observations(CreatePriceObservationsPlanInput { - context: &context, - pool_id: account_id(&pool_id, "poolId")?, - window_duration: decimal_u64(&window_duration, "windowDuration")?, - }) + transaction_plan_json(&plan_create_price_observations( + CreatePriceObservationsPlanInput { + context: &context, + pool_id: account_id(&pool_id, "poolId")?, + window_duration: decimal_u64(&window_duration, "windowDuration")?, + }, + )) } PlanRequest::CreateOraclePriceAccount { context, @@ -890,11 +903,13 @@ pub fn plan_json(value: Value) -> Result { window_duration, } => { let context = context.into_context()?; - plan_create_oracle_price_account(CreateOraclePriceAccountPlanInput { - context: &context, - pool_id: account_id(&pool_id, "poolId")?, - window_duration: decimal_u64(&window_duration, "windowDuration")?, - }) + transaction_plan_json(&plan_create_oracle_price_account( + CreateOraclePriceAccountPlanInput { + context: &context, + pool_id: account_id(&pool_id, "poolId")?, + window_duration: decimal_u64(&window_duration, "windowDuration")?, + }, + )) } PlanRequest::CreatePool { context, @@ -909,7 +924,7 @@ pub fn plan_json(value: Value) -> Result { deadline, } => { let context = context.into_context()?; - plan_create_pool(CreatePoolPlanInput { + transaction_plan_json(&plan_create_pool(CreatePoolPlanInput { context: &context, token_a_definition_id: account_id(&token_a_definition_id, "tokenADefinitionId")?, token_b_definition_id: account_id(&token_b_definition_id, "tokenBDefinitionId")?, @@ -920,7 +935,7 @@ pub fn plan_json(value: Value) -> Result { token_b_amount: decimal_u128(&token_b_amount, "tokenBAmount")?, fees: decimal_u128(&fees, "fees")?, deadline: decimal_u64(&deadline, "deadline")?, - })? + })?) } PlanRequest::AddLiquidity { context, @@ -935,7 +950,7 @@ pub fn plan_json(value: Value) -> Result { } => { let context = context.into_context()?; let (pool_id, pool) = pool.into_pool()?; - plan_add_liquidity(AddLiquidityPlanInput { + transaction_plan_json(&plan_add_liquidity(AddLiquidityPlanInput { context: &context, pool: PoolContext::new(&context, pool_id, &pool)?, user_holding_a: account_id(&user_holding_a, "userHoldingA")?, @@ -951,7 +966,7 @@ pub fn plan_json(value: Value) -> Result { "maxAmountToAddTokenB", )?, deadline: decimal_u64(&deadline, "deadline")?, - }) + })) } PlanRequest::RemoveLiquidity { context, @@ -966,7 +981,7 @@ pub fn plan_json(value: Value) -> Result { } => { let context = context.into_context()?; let (pool_id, pool) = pool.into_pool()?; - plan_remove_liquidity(RemoveLiquidityPlanInput { + transaction_plan_json(&plan_remove_liquidity(RemoveLiquidityPlanInput { context: &context, pool: PoolContext::new(&context, pool_id, &pool)?, user_holding_a: account_id(&user_holding_a, "userHoldingA")?, @@ -985,7 +1000,7 @@ pub fn plan_json(value: Value) -> Result { "minAmountToRemoveTokenB", )?, deadline: decimal_u64(&deadline, "deadline")?, - }) + })) } PlanRequest::SwapExactInput { context, @@ -998,7 +1013,7 @@ pub fn plan_json(value: Value) -> Result { } => { let context = context.into_context()?; let (pool_id, pool) = pool.into_pool()?; - plan_swap_exact_input(SwapExactInputPlanInput { + transaction_plan_json(&plan_swap_exact_input(SwapExactInputPlanInput { context: &context, pool: PoolContext::new(&context, pool_id, &pool)?, user_input_holding: account_id(&user_input_holding, "userInputHolding")?, @@ -1006,7 +1021,7 @@ pub fn plan_json(value: Value) -> Result { swap_amount_in: decimal_u128(&swap_amount_in, "swapAmountIn")?, min_amount_out: decimal_u128(&min_amount_out, "minAmountOut")?, deadline: decimal_u64(&deadline, "deadline")?, - }) + })) } PlanRequest::SwapExactOutput { context, @@ -1019,7 +1034,7 @@ pub fn plan_json(value: Value) -> Result { } => { let context = context.into_context()?; let (pool_id, pool) = pool.into_pool()?; - plan_swap_exact_output(SwapExactOutputPlanInput { + transaction_plan_json(&plan_swap_exact_output(SwapExactOutputPlanInput { context: &context, pool: PoolContext::new(&context, pool_id, &pool)?, user_input_holding: account_id(&user_input_holding, "userInputHolding")?, @@ -1027,117 +1042,17 @@ pub fn plan_json(value: Value) -> Result { exact_amount_out: decimal_u128(&exact_amount_out, "exactAmountOut")?, max_amount_in: decimal_u128(&max_amount_in, "maxAmountIn")?, deadline: decimal_u64(&deadline, "deadline")?, - }) + })) } PlanRequest::SyncReserves { context, pool } => { let context = context.into_context()?; let (pool_id, pool) = pool.into_pool()?; - plan_sync_reserves(SyncReservesPlanInput { + transaction_plan_json(&plan_sync_reserves(SyncReservesPlanInput { context: &context, pool: PoolContext::new(&context, pool_id, &pool)?, - }) - } - }; - - versioned(transaction_plan_json(&plan)) -} - -fn is_prepared_transaction_operation(operation: &str) -> bool { - matches!( - operation, - "prepare_create_pool_transaction" - | "prepare_add_liquidity_transaction" - | "prepare_remove_liquidity_transaction" - | "prepare_swap_exact_input_transaction" - | "prepare_swap_exact_output_transaction" - ) -} - -/// Evaluates one reusable AMM economic quote from tagged JSON. -pub fn quote_json(value: Value) -> Result { - validate_wire_schema(&value)?; - let request: QuoteRequest = serde_json::from_value(value) - .map_err(|error| invalid_request(format!("invalid quote request: {error}")))?; - versioned(match request { - QuoteRequest::ProtocolConstants => Ok(json!({ - "minimumLiquidity": MINIMUM_LIQUIDITY.to_string(), - "feeBpsDenominator": FEE_BPS_DENOMINATOR.to_string(), - "slippageBpsDenominator": SLIPPAGE_BPS_DENOMINATOR.to_string(), - "supportedFeeTiers": SUPPORTED_FEE_TIERS - .iter() - .map(u128::to_string) - .collect::>(), - })), - QuoteRequest::DeriveConfigId { amm_program_id } => Ok(json!({ - "configId": discovery::derive_config_id(amm_program_id).to_string(), - })), - QuoteRequest::InspectConfig { - amm_program_id, - config, - } => { - let config = config.into_snapshot()?; - let context = discovery::inspect_config(amm_program_id, &config)?; - Ok(amm_context_json(&context)) - } - QuoteRequest::CanonicalPair { - first_token_definition_id, - second_token_definition_id, - } => { - let pair = discovery::canonical_pair( - account_id(&first_token_definition_id, "firstTokenDefinitionId")?, - account_id(&second_token_definition_id, "secondTokenDefinitionId")?, - )?; - Ok(canonical_pair_json(pair)) - } - QuoteRequest::DerivePairReadManifest { - amm_program_id, - config, - first_token_definition_id, - second_token_definition_id, - } => { - let config = config.into_snapshot()?; - let context = discovery::inspect_config(amm_program_id, &config)?; - let manifest = discovery::derive_pair_read_manifest( - &context, - account_id(&first_token_definition_id, "firstTokenDefinitionId")?, - account_id(&second_token_definition_id, "secondTokenDefinitionId")?, - )?; - Ok(pair_read_manifest_json(manifest)) + })) } - QuoteRequest::InspectPair { - amm_program_id, - config, - first_token_definition_id, - second_token_definition_id, - snapshots, - } => { - let config = config.into_snapshot()?; - let context = discovery::inspect_config(amm_program_id, &config)?; - let snapshots = snapshots.into_snapshots()?; - let inspected = discovery::inspect_pair( - &context, - account_id(&first_token_definition_id, "firstTokenDefinitionId")?, - account_id(&second_token_definition_id, "secondTokenDefinitionId")?, - snapshots.as_borrowed(), - )?; - Ok(pair_inspection_json(inspected)) - } - QuoteRequest::PrepareCallerOpeningPair { - first_token_definition_id, - second_token_definition_id, - desired_price_q64_64, - fee_bps, - intent, - } => Ok(prepared_caller_opening_pair_json( - crate::prepare_caller_opening_pair( - account_id(&first_token_definition_id, "firstTokenDefinitionId")?, - account_id(&second_token_definition_id, "secondTokenDefinitionId")?, - decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, - decimal_u128(&fee_bps, "feeBps")?, - intent.into_intent()?, - )?, - )), - QuoteRequest::PrepareCreatePoolTransaction { + PlanRequest::PrepareCreatePoolTransaction { amm_program_id, config, snapshots, @@ -1158,7 +1073,7 @@ pub fn quote_json(value: Value) -> Result { let liquidity_holding = liquidity_holding.into_snapshot()?; let prepared = crate::prepare_create_pool_transaction(crate::CreatePoolTransactionInput { - amm_program_id, + amm_program_id: amm_program_id.into(), config: &config, pair: snapshots.as_borrowed(), first_token_definition_id: account_id( @@ -1179,7 +1094,7 @@ pub fn quote_json(value: Value) -> Result { })?; prepared_transaction_json(&prepared, create_pool_quote_json(*prepared.quote())) } - QuoteRequest::PrepareAddLiquidityTransaction { + PlanRequest::PrepareAddLiquidityTransaction { amm_program_id, config, snapshots, @@ -1201,7 +1116,7 @@ pub fn quote_json(value: Value) -> Result { let liquidity_holding = liquidity_holding.into_snapshot()?; let prepared = crate::prepare_add_liquidity_transaction(crate::AddLiquidityTransactionInput { - amm_program_id, + amm_program_id: amm_program_id.into(), pool_accounts: crate::PoolAccountSnapshots { config: &config, pair: snapshots.as_borrowed(), @@ -1225,7 +1140,7 @@ pub fn quote_json(value: Value) -> Result { })?; prepared_transaction_json(&prepared, add_liquidity_quote_json(*prepared.quote())) } - QuoteRequest::PrepareRemoveLiquidityTransaction { + PlanRequest::PrepareRemoveLiquidityTransaction { amm_program_id, config, snapshots, @@ -1246,7 +1161,7 @@ pub fn quote_json(value: Value) -> Result { let liquidity_holding = liquidity_holding.into_snapshot()?; let prepared = crate::prepare_remove_liquidity_transaction( crate::RemoveLiquidityTransactionInput { - amm_program_id, + amm_program_id: amm_program_id.into(), pool_accounts: crate::PoolAccountSnapshots { config: &config, pair: snapshots.as_borrowed(), @@ -1273,7 +1188,7 @@ pub fn quote_json(value: Value) -> Result { )?; prepared_transaction_json(&prepared, remove_liquidity_quote_json(*prepared.quote())) } - QuoteRequest::PrepareSwapExactInputTransaction { + PlanRequest::PrepareSwapExactInputTransaction { amm_program_id, config, snapshots, @@ -1292,7 +1207,7 @@ pub fn quote_json(value: Value) -> Result { let output_holding = output_holding.into_snapshot()?; let prepared = crate::prepare_swap_exact_input_transaction( crate::SwapExactInputTransactionInput { - amm_program_id, + amm_program_id: amm_program_id.into(), pool_accounts: crate::PoolAccountSnapshots { config: &config, pair: snapshots.as_borrowed(), @@ -1315,7 +1230,7 @@ pub fn quote_json(value: Value) -> Result { )?; prepared_transaction_json(&prepared, swap_quote_json(*prepared.quote())) } - QuoteRequest::PrepareSwapExactOutputTransaction { + PlanRequest::PrepareSwapExactOutputTransaction { amm_program_id, config, snapshots, @@ -1334,7 +1249,7 @@ pub fn quote_json(value: Value) -> Result { let output_holding = output_holding.into_snapshot()?; let prepared = crate::prepare_swap_exact_output_transaction( crate::SwapExactOutputTransactionInput { - amm_program_id, + amm_program_id: amm_program_id.into(), pool_accounts: crate::PoolAccountSnapshots { config: &config, pair: snapshots.as_borrowed(), @@ -1357,6 +1272,93 @@ pub fn quote_json(value: Value) -> Result { )?; prepared_transaction_json(&prepared, swap_quote_json(*prepared.quote())) } + }) +} + +/// Evaluates one reusable AMM economic quote from tagged JSON. +pub fn quote_json(value: Value) -> Result { + validate_wire_schema(&value)?; + let request: QuoteRequest = serde_json::from_value(value) + .map_err(|error| invalid_request(format!("invalid quote request: {error}")))?; + versioned(match request { + QuoteRequest::ProtocolConstants => Ok(json!({ + "minimumLiquidity": MINIMUM_LIQUIDITY.to_string(), + "feeBpsDenominator": FEE_BPS_DENOMINATOR.to_string(), + "slippageBpsDenominator": SLIPPAGE_BPS_DENOMINATOR.to_string(), + "supportedFeeTiers": SUPPORTED_FEE_TIERS + .iter() + .map(u128::to_string) + .collect::>(), + })), + QuoteRequest::DeriveConfigId { amm_program_id } => Ok(json!({ + "configId": discovery::derive_config_id(amm_program_id.into()).to_string(), + })), + QuoteRequest::InspectConfig { + amm_program_id, + config, + } => { + let config = config.into_snapshot()?; + let context = discovery::inspect_config(amm_program_id.into(), &config)?; + Ok(amm_context_json(&context)) + } + QuoteRequest::CanonicalPair { + first_token_definition_id, + second_token_definition_id, + } => { + let pair = discovery::canonical_pair( + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + )?; + Ok(canonical_pair_json(pair)) + } + QuoteRequest::DerivePairReadManifest { + amm_program_id, + config, + first_token_definition_id, + second_token_definition_id, + } => { + let config = config.into_snapshot()?; + let context = discovery::inspect_config(amm_program_id.into(), &config)?; + let manifest = discovery::derive_pair_read_manifest( + &context, + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + )?; + Ok(pair_read_manifest_json(manifest)) + } + QuoteRequest::InspectPair { + amm_program_id, + config, + first_token_definition_id, + second_token_definition_id, + snapshots, + } => { + let config = config.into_snapshot()?; + let context = discovery::inspect_config(amm_program_id.into(), &config)?; + let snapshots = snapshots.into_snapshots()?; + let inspected = discovery::inspect_pair( + &context, + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + snapshots.as_borrowed(), + )?; + Ok(pair_inspection_json(inspected)) + } + QuoteRequest::PrepareCallerOpeningPair { + first_token_definition_id, + second_token_definition_id, + desired_price_q64_64, + fee_bps, + intent, + } => Ok(prepared_caller_opening_pair_json( + crate::prepare_caller_opening_pair( + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + intent.into_intent()?, + )?, + )), QuoteRequest::PrepareMinimumOpeningPair { desired_price_q64_64, fee_bps, @@ -1429,7 +1431,7 @@ pub fn quote_json(value: Value) -> Result { fee_bps, } => { let config = config.into_snapshot()?; - let context = AmmContext::from_config_account(amm_program_id, &config)?; + let context = AmmContext::from_config_account(amm_program_id.into(), &config)?; let token_a_definition = token_a_definition.into_snapshot()?; let token_b_definition = token_b_definition.into_snapshot()?; let token_a = ValidatedFungibleDefinition::new(&context, &token_a_definition)?; @@ -1454,7 +1456,7 @@ pub fn quote_json(value: Value) -> Result { fee_bps, } => { let config = config.into_snapshot()?; - let context = AmmContext::from_config_account(amm_program_id, &config)?; + let context = AmmContext::from_config_account(amm_program_id.into(), &config)?; let token_a_definition = token_a_definition.into_snapshot()?; let token_b_definition = token_b_definition.into_snapshot()?; let token_a = ValidatedFungibleDefinition::new(&context, &token_a_definition)?; @@ -1763,7 +1765,7 @@ fn transaction_plan_json(plan: &TransactionPlan) -> Result { Ok(json!({ "instruction": plan.instruction_name(), "instructionArgs": instruction_args_json(plan.instruction()), - "programId": plan.program_id(), + "programId": program_id_hex(plan.program_id()), "accounts": accounts, "affectedAccountIds": plan .affected_account_ids() @@ -1781,8 +1783,8 @@ fn instruction_args_json(instruction: &Instruction) -> Value { twap_oracle_program_id, authority, } => json!({ - "tokenProgramId": token_program_id, - "twapOracleProgramId": twap_oracle_program_id, + "tokenProgramId": program_id_hex(*token_program_id), + "twapOracleProgramId": program_id_hex(*twap_oracle_program_id), "authority": authority.to_string(), }), Instruction::UpdateConfig { @@ -1790,8 +1792,8 @@ fn instruction_args_json(instruction: &Instruction) -> Value { twap_oracle_program_id, new_authority, } => json!({ - "tokenProgramId": token_program_id, - "twapOracleProgramId": twap_oracle_program_id, + "tokenProgramId": token_program_id.map(program_id_hex), + "twapOracleProgramId": twap_oracle_program_id.map(program_id_hex), "newAuthority": new_authority.map(|authority| authority.to_string()), }), Instruction::CreatePriceObservations { window_duration } @@ -1944,10 +1946,10 @@ fn pool_update_json(pool: PoolUpdate) -> Value { fn amm_context_json(context: &AmmContext) -> Value { json!({ - "ammProgramId": context.amm_program_id, + "ammProgramId": program_id_hex(context.amm_program_id), "configId": context.config_id().to_string(), - "tokenProgramId": context.token_program_id(), - "twapOracleProgramId": context.twap_oracle_program_id(), + "tokenProgramId": program_id_hex(context.token_program_id()), + "twapOracleProgramId": program_id_hex(context.twap_oracle_program_id()), "authority": context.config.authority.to_string(), }) } @@ -2255,9 +2257,46 @@ fn oracle_price_quote_json(quote: OraclePriceAccountQuote) -> Value { }) } +fn parse_program_id(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(String::from( + "program ID must be exactly 64 lowercase hexadecimal characters", + )); + } + + let bytes = hex_bytes(value, "program ID").map_err(|error| error.to_string())?; + let mut program_id = [0_u32; 8]; + for (word, bytes) in program_id.iter_mut().zip(bytes.chunks_exact(4)) { + let mut word_bytes = [0_u8; 4]; + word_bytes.copy_from_slice(bytes); + *word = u32::from_le_bytes(word_bytes); + } + Ok(program_id) +} + +fn program_id_hex(program_id: ProgramId) -> String { + let mut output = String::with_capacity(64); + for word in program_id { + for byte in word.to_le_bytes() { + output.push_str(&format!("{byte:02x}")); + } + } + output +} + fn account_id(value: &str, field: &str) -> Result { - AccountId::from_str(value) - .map_err(|error| invalid_request(format!("{field} is not a valid account ID: {error}"))) + let account_id = AccountId::from_str(value) + .map_err(|error| invalid_request(format!("{field} is not a valid account ID: {error}")))?; + if account_id.to_string() != value { + return Err(invalid_request(format!( + "{field} must use canonical base58 encoding" + ))); + } + Ok(account_id) } fn decimal_u128(value: &str, field: &str) -> Result { diff --git a/programs/amm/client/tests/common/mod.rs b/programs/amm/client/tests/common/mod.rs new file mode 100644 index 00000000..e4dfc60f --- /dev/null +++ b/programs/amm/client/tests/common/mod.rs @@ -0,0 +1,11 @@ +use nssa_core::program::ProgramId; + +pub fn program_id_hex(program_id: ProgramId) -> String { + let mut output = String::with_capacity(64); + for word in program_id { + for byte in word.to_le_bytes() { + output.push_str(&format!("{byte:02x}")); + } + } + output +} diff --git a/programs/amm/client/tests/ffi_contract.rs b/programs/amm/client/tests/ffi_contract.rs index a8c9d380..72a31ee8 100644 --- a/programs/amm/client/tests/ffi_contract.rs +++ b/programs/amm/client/tests/ffi_contract.rs @@ -3,6 +3,8 @@ reason = "contract tests call the exported C ABI and release its owned pointers" )] +mod common; + use std::ffi::{c_char, CStr, CString}; use amm_client::{amm_client_free, amm_client_plan, amm_client_quote, wire::WIRE_SCHEMA}; @@ -10,6 +12,7 @@ use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; +use common::program_id_hex; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -45,7 +48,7 @@ fn call_json(operation: Operation, request: &Value) -> Value { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": account.program_owner, + "programOwner": program_id_hex(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": hex(account.data.as_ref()), @@ -147,6 +150,31 @@ fn protocol_constants_are_exposed_without_numeric_json_values() { ); } +#[test] +fn program_ids_require_canonical_lowercase_hex_strings() { + let canonical = program_id_hex([0xabcdef01; 8]); + let authority = AccountId::new([44; 32]).to_string(); + + for invalid in [ + json!([1, 1, 1, 1, 1, 1, 1, 1]), + json!(canonical.to_uppercase()), + ] { + let response = call_json( + amm_client_plan, + &json!({ + "operation": "initialize", + "ammProgramId": invalid, + "tokenProgramId": canonical, + "twapOracleProgramId": canonical, + "authority": authority, + }), + ); + + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_request"); + } +} + #[test] fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() { let amm_program_id: ProgramId = [11; 8]; @@ -160,9 +188,9 @@ fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() { &json!({ "operation": "create_price_observations", "context": { - "ammProgramId": amm_program_id, - "tokenProgramId": token_program_id, - "twapOracleProgramId": twap_oracle_program_id, + "ammProgramId": program_id_hex(amm_program_id), + "tokenProgramId": program_id_hex(token_program_id), + "twapOracleProgramId": program_id_hex(twap_oracle_program_id), "authority": authority.to_string(), }, "poolId": pool_id.to_string(), @@ -175,7 +203,10 @@ fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() { response["value"]["instruction"], "create_price_observations" ); - assert_eq!(response["value"]["programId"], json!(amm_program_id)); + assert_eq!( + response["value"]["programId"], + program_id_hex(amm_program_id) + ); assert!(response["value"]["accounts"].is_array()); let words: Vec = serde_json::from_value(response["value"]["instructionWords"].clone()) .expect("instruction words must be u32 JSON numbers"); @@ -232,7 +263,7 @@ fn successful_quote_preserves_u128_above_javascript_range_as_decimal() { amm_client_quote, &json!({ "operation": "create_pool", - "ammProgramId": amm_program_id, + "ammProgramId": program_id_hex(amm_program_id), "config": snapshot(compute_config_pda(amm_program_id), &config_account), "tokenADefinition": snapshot(token_a_id, &definition("A")), "tokenBDefinition": snapshot(token_b_id, &definition("B")), @@ -258,7 +289,7 @@ fn successful_quote_preserves_u128_above_javascript_range_as_decimal() { amm_client_quote, &json!({ "operation": "prepare_create_pool", - "ammProgramId": amm_program_id, + "ammProgramId": program_id_hex(amm_program_id), "config": snapshot(compute_config_pda(amm_program_id), &config_account), "tokenADefinition": snapshot(token_a_id, &definition("A")), "tokenBDefinition": snapshot(token_b_id, &definition("B")), @@ -311,7 +342,7 @@ fn swap_quote_rejects_unrelated_output_holding() { amm_client_quote, &json!({ "operation": "preview_swap_exact_input", - "ammProgramId": amm_program_id, + "ammProgramId": program_id_hex(amm_program_id), "config": snapshot( compute_config_pda(amm_program_id), &account(amm_program_id, Data::from(&config)), diff --git a/programs/amm/client/tests/wire_discovery_intent_contract.rs b/programs/amm/client/tests/wire_discovery_intent_contract.rs index 498d9fd1..0420a444 100644 --- a/programs/amm/client/tests/wire_discovery_intent_contract.rs +++ b/programs/amm/client/tests/wire_discovery_intent_contract.rs @@ -1,9 +1,12 @@ +mod common; + use amm_client::wire::{quote_json, WIRE_SCHEMA}; use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, compute_vault_pda, AmmConfig, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use common::program_id_hex; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -108,7 +111,7 @@ impl PairIds { fn inspect_request(&self, snapshots: Value) -> Value { json!({ "operation": "inspect_pair", - "ammProgramId": AMM_PROGRAM_ID, + "ammProgramId": program_id_hex(AMM_PROGRAM_ID), "config": config_snapshot(), "firstTokenDefinitionId": self.first_token_id.to_string(), "secondTokenDefinitionId": self.second_token_id.to_string(), @@ -202,7 +205,7 @@ impl PairIds { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": account.program_owner, + "programOwner": program_id_hex(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -229,7 +232,7 @@ fn discovery_operations_return_exact_string_account_ids() { let config_id = quote_json(json!({ "operation": "derive_config_id", - "ammProgramId": AMM_PROGRAM_ID, + "ammProgramId": program_id_hex(AMM_PROGRAM_ID), })) .expect("legacy schema-less request remains accepted"); assert_eq!(config_id["schema"], WIRE_SCHEMA); @@ -242,16 +245,19 @@ fn discovery_operations_return_exact_string_account_ids() { let inspected = quote_json(json!({ "schema": WIRE_SCHEMA, "operation": "inspect_config", - "ammProgramId": AMM_PROGRAM_ID, + "ammProgramId": program_id_hex(AMM_PROGRAM_ID), "config": config.clone(), })) .expect("config must inspect"); assert_eq!(inspected["schema"], WIRE_SCHEMA); - assert_eq!(inspected["ammProgramId"], json!(AMM_PROGRAM_ID)); - assert_eq!(inspected["tokenProgramId"], json!(TOKEN_PROGRAM_ID)); + assert_eq!(inspected["ammProgramId"], program_id_hex(AMM_PROGRAM_ID)); + assert_eq!( + inspected["tokenProgramId"], + program_id_hex(TOKEN_PROGRAM_ID) + ); assert_eq!( inspected["twapOracleProgramId"], - json!(TWAP_ORACLE_PROGRAM_ID) + program_id_hex(TWAP_ORACLE_PROGRAM_ID) ); assert_eq!(inspected["authority"], AccountId::new([9; 32]).to_string()); @@ -266,7 +272,7 @@ fn discovery_operations_return_exact_string_account_ids() { let manifest = quote_json(json!({ "operation": "derive_pair_read_manifest", - "ammProgramId": AMM_PROGRAM_ID, + "ammProgramId": program_id_hex(AMM_PROGRAM_ID), "config": config, "firstTokenDefinitionId": first_token_id.to_string(), "secondTokenDefinitionId": second_token_id.to_string(), diff --git a/programs/amm/client/tests/wire_prepare_contract.rs b/programs/amm/client/tests/wire_prepare_contract.rs index a8ba37be..f4d2e688 100644 --- a/programs/amm/client/tests/wire_prepare_contract.rs +++ b/programs/amm/client/tests/wire_prepare_contract.rs @@ -1,8 +1,11 @@ +mod common; + use amm_client::{maximum_guard_amount, minimum_guard_amount, wire::quote_json, SlippageTolerance}; use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, AmmConfig, PoolDefinition, FEE_TIER_BPS_30, }; +use common::program_id_hex; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -26,7 +29,7 @@ fn account(program_owner: ProgramId, data: Data) -> Account { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": account.program_owner, + "programOwner": program_id_hex(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -99,7 +102,7 @@ impl WireFixture { fees: FEE_TIER_BPS_30, }; let state = json!({ - "ammProgramId": AMM_PROGRAM_ID, + "ammProgramId": program_id_hex(AMM_PROGRAM_ID), "config": config, "snapshot": { "pool": snapshot(pool_id, &account(AMM_PROGRAM_ID, Data::from(&pool))), @@ -161,7 +164,7 @@ fn prepare_wire_operations_return_lossless_instruction_args() { let create = quote_json(json!({ "operation": "prepare_create_pool", - "ammProgramId": AMM_PROGRAM_ID, + "ammProgramId": program_id_hex(AMM_PROGRAM_ID), "config": fixture.config.clone(), "tokenADefinition": snapshot(fixture.token_a_id, &definition(100_000, None)), "tokenBDefinition": snapshot(fixture.token_b_id, &definition(100_000, None)), diff --git a/programs/amm/client/tests/wire_transaction_contract.rs b/programs/amm/client/tests/wire_transaction_contract.rs index 2359f84a..f89020fc 100644 --- a/programs/amm/client/tests/wire_transaction_contract.rs +++ b/programs/amm/client/tests/wire_transaction_contract.rs @@ -1,9 +1,12 @@ +mod common; + use amm_client::wire::{plan_json, quote_json}; use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use common::program_id_hex; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -52,7 +55,7 @@ fn holding(definition_id: AccountId, balance: u128) -> Account { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": account.program_owner, + "programOwner": program_id_hex(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -209,7 +212,7 @@ impl TransactionFixture { fn active_common(&self, operation: &str) -> Value { json!({ "operation": operation, - "ammProgramId": AMM_PROGRAM_ID, + "ammProgramId": program_id_hex(AMM_PROGRAM_ID), "config": self.config.clone(), "snapshots": self.active_snapshots.clone(), "firstTokenDefinitionId": self.first_token_id.to_string(), @@ -226,7 +229,7 @@ impl TransactionFixture { fn swap_common(&self, operation: &str) -> Value { json!({ "operation": operation, - "ammProgramId": AMM_PROGRAM_ID, + "ammProgramId": program_id_hex(AMM_PROGRAM_ID), "config": self.config.clone(), "snapshots": self.active_snapshots.clone(), "inputTokenDefinitionId": self.first_token_id.to_string(), @@ -321,9 +324,9 @@ fn five_transaction_operations_emit_exact_plans_and_task_artifacts() { let fixture = TransactionFixture::new(); let second_amount = LARGE.checked_mul(2).expect("test amount fits"); - let create = quote_json(json!({ + let create = plan_json(json!({ "operation": "prepare_create_pool_transaction", - "ammProgramId": AMM_PROGRAM_ID, + "ammProgramId": program_id_hex(AMM_PROGRAM_ID), "config": fixture.config.clone(), "snapshots": fixture.missing_snapshots.clone(), "firstTokenDefinitionId": fixture.first_token_id.to_string(), @@ -379,11 +382,10 @@ fn five_transaction_operations_emit_exact_plans_and_task_artifacts() { let mut add_request = fixture.active_common("prepare_add_liquidity_transaction"); insert(&mut add_request, "maxFirstAmount", json!("100")); insert(&mut add_request, "maxSecondAmount", json!("400")); - let add = quote_json(add_request.clone()).expect("add transaction must prepare"); - assert_eq!( - plan_json(add_request).expect("plan entrypoint must prepare task transactions"), - add - ); + let quote_error = quote_json(add_request.clone()) + .expect_err("quote endpoint must reject transaction preparation"); + assert_eq!(quote_error.code(), "invalid_request"); + let add = plan_json(add_request).expect("add transaction must prepare"); assert_common_contract(&add, "add_liquidity", false); assert_eq!(add["callerAmounts"]["first"], "100"); assert_eq!(add["callerAmounts"]["second"], "200"); @@ -428,7 +430,7 @@ fn five_transaction_operations_emit_exact_plans_and_task_artifacts() { let mut remove_request = fixture.active_common("prepare_remove_liquidity_transaction"); insert(&mut remove_request, "removeLiquidityAmount", json!("500")); - let remove = quote_json(remove_request).expect("remove transaction must prepare"); + let remove = plan_json(remove_request).expect("remove transaction must prepare"); assert_common_contract(&remove, "remove_liquidity", false); assert_eq!(remove["callerAmounts"]["first"], "125"); assert_eq!(remove["callerAmounts"]["second"], "250"); @@ -469,8 +471,7 @@ fn five_transaction_operations_emit_exact_plans_and_task_artifacts() { let mut exact_input_request = fixture.swap_common("prepare_swap_exact_input_transaction"); insert(&mut exact_input_request, "amountIn", json!("100")); - let exact_input = - quote_json(exact_input_request).expect("exact-input transaction must prepare"); + let exact_input = plan_json(exact_input_request).expect("exact-input transaction must prepare"); assert_common_contract(&exact_input, "swap_exact_input", true); assert_eq!(exact_input["callerAmounts"]["first"], "100"); assert_eq!( @@ -503,7 +504,7 @@ fn five_transaction_operations_emit_exact_plans_and_task_artifacts() { let mut exact_output_request = fixture.swap_common("prepare_swap_exact_output_transaction"); insert(&mut exact_output_request, "exactAmountOut", json!("100")); let exact_output = - quote_json(exact_output_request).expect("exact-output transaction must prepare"); + plan_json(exact_output_request).expect("exact-output transaction must prepare"); assert_common_contract(&exact_output, "swap_exact_output", true); assert_eq!(exact_output["callerAmounts"]["second"], "100"); match decode_instruction(&exact_output) { @@ -560,6 +561,6 @@ fn transaction_wire_rejects_expected_fee_mismatch() { insert(&mut request, "maxSecondAmount", json!("400")); insert(&mut request, "expectedFeeBps", json!("100")); - let error = quote_json(request).expect_err("wrong expected fee must fail"); + let error = plan_json(request).expect_err("wrong expected fee must fail"); assert_eq!(error.code(), "fee_mismatch"); } From cfa2365e1666c7745819da82c73a9cc3b87a342a Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 18:24:24 -0300 Subject: [PATCH 6/8] feat(amm-client): add lossless host adapters --- programs/amm/client/README.md | 8 +- programs/amm/client/docs/wire-api.md | 20 +- programs/amm/client/include/amm_client.h | 3 +- programs/amm/client/src/ffi.rs | 2 +- programs/amm/client/src/intent.rs | 186 ++++++++++++++++++ programs/amm/client/src/lib.rs | 14 +- programs/amm/client/src/sequencer.rs | 99 ++++++++++ programs/amm/client/src/wire.rs | 89 ++++++++- .../client/tests/consumer_adapter_contract.rs | 91 +++++++++ 9 files changed, 494 insertions(+), 18 deletions(-) create mode 100644 programs/amm/client/src/sequencer.rs create mode 100644 programs/amm/client/tests/consumer_adapter_contract.rs diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md index b6593aea..55c0944e 100644 --- a/programs/amm/client/README.md +++ b/programs/amm/client/README.md @@ -15,7 +15,10 @@ adapter responsibilities. - `discovery` derives config and complete pair read manifests, then classifies raw pair snapshots as missing or active without performing network I/O. - `intent` prepares canonical opening amounts and caller/stored order mappings with integer-only - protocol math. + protocol math. It also converts exact human price ratios and token decimals into stored-order + Q64.64 prices without floating point. +- `sequencer` decodes original `getAccount` response text directly into lossless + `AccountSnapshot` values, preserving integer fields above `2^53`. - `slippage` converts validated quotes into integer-only instruction guards. Minimum guards round down, maximum guards round up, and checked overflow returns a typed error. - `plan` covers all ten guest instructions and returns the canonical instruction plus ordered @@ -76,4 +79,5 @@ Program IDs use 64-character lowercase hexadecimal strings. Account data uses he encoded instruction words remain JSON `u32` numbers. No JavaScript `Number` conversion is required for chain amounts or deadlines. Plan JSON also includes typed `instructionArgs`, derived directly from the same `amm_core::Instruction` encoded in `instructionWords`. Only `amm_client_plan` accepts -the five snapshot-bound `prepare_*_transaction` operations. +the five snapshot-bound `prepare_*_transaction` operations. The quote entrypoint also exposes +`account_snapshot_from_sequencer_response` and `human_price_ratio_to_q64_64` host adapters. diff --git a/programs/amm/client/docs/wire-api.md b/programs/amm/client/docs/wire-api.md index b533215a..b0aa99d6 100644 --- a/programs/amm/client/docs/wire-api.md +++ b/programs/amm/client/docs/wire-api.md @@ -159,6 +159,8 @@ shown in this table and the sections below. | `operation` | Additional fields | |---|---| | `protocol_constants` | none; returns decimal-string `minimumLiquidity`, `feeBpsDenominator`, `slippageBpsDenominator`, and `supportedFeeTiers` | +| `account_snapshot_from_sequencer_response` | canonical base58 `accountId`, original `getAccount` response text in `response` | +| `human_price_ratio_to_q64_64` | caller-ordered token IDs, `firstAmount`, `secondAmount`, and decimal-string `firstTokenDecimals`/`secondTokenDecimals` | | `derive_config_id` | `ammProgramId` | | `inspect_config` | `ammProgramId`, raw `config` snapshot | | `canonical_pair` | `firstTokenDefinitionId`, `secondTokenDefinitionId` | @@ -200,6 +202,20 @@ Quote values use these result shapes: A `pool` result contains decimal-string `liquidityPoolSupply`, `reserveA`, `reserveB`, and `spotPriceQ64_64` fields. +## Host adapters + +`account_snapshot_from_sequencer_response` accepts the original JSON-RPC response as a JSON string, +not a host-parsed object. It decodes sequencer numeric literals directly as Rust `u128` values and +returns the standard snapshot fields: `id`, `programOwner`, `balance`, `nonce`, and `data`. This +preserves balances and nonces above `2^53`. Do not route the response through a JavaScript or QML +numeric value first. + +`human_price_ratio_to_q64_64` declares that `firstAmount` human units of the first token equal +`secondAmount` human units of the second token. Amounts are unsigned decimal text and may contain +up to 38 fractional digits. Token decimals are accepted from `0` through `38`. The adapter derives +stored token A/B order from the token IDs, applies unequal token decimals, floors once, and returns +decimal-string `priceQ64_64`. Callers keep display order; reversed pairs must not invert locally. + ## Discovery, inspection, and opening intents Discovery functions derive IDs only; adapters fetch the returned accounts and submit raw @@ -315,7 +331,9 @@ Every failure uses `{ "code": "...", "message": "..." }`. `code` is the stable machine-readable contract; `message` is diagnostic text. JSON adapter failures return `invalid_request` or `unsupported_schema`. The C envelope additionally returns `null_request`, `invalid_utf8`, `invalid_json`, `response_serialization_failed`, or `response_contains_nul` for -boundary failures. +boundary failures. Sequencer adapters return `invalid_sequencer_response`, +`sequencer_account_error`, `sequencer_account_missing`, or `account_data_too_large`. Human-price +conversion uses the stable `IntentError` codes documented by the Rust API. No request performs network I/O or checks an ImageID, release version, compatibility manifest, or program allowlist. Deployment configuration is expected to select the corresponding AMM build. diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h index 54446442..484495e7 100644 --- a/programs/amm/client/include/amm_client.h +++ b/programs/amm/client/include/amm_client.h @@ -19,7 +19,8 @@ char *amm_client_plan(const char *request_json); * Accepts a tagged UTF-8 JSON request and returns an owned UTF-8 JSON envelope. * Supported operation tags include protocol constants; config and pair discovery; * pair inspection; caller-order opening preparation; economic quote/preparation - * operations; reserve synchronization; and oracle-price initialization. + * operations; reserve synchronization; oracle-price initialization; raw sequencer + * account normalization; and human-price Q64.64 conversion. * Snapshot-bound prepare_*_transaction operations belong to amm_client_plan. * See docs/wire-api.md for fields. * Release the result with amm_client_free. diff --git a/programs/amm/client/src/ffi.rs b/programs/amm/client/src/ffi.rs index 93b4f169..ba11f135 100644 --- a/programs/amm/client/src/ffi.rs +++ b/programs/amm/client/src/ffi.rs @@ -145,7 +145,7 @@ pub unsafe extern "C" fn amm_client_plan(request_json: *const c_char) -> *mut c_ unsafe { call(request_json, wire::plan_json) } } -/// Evaluates a canonical AMM economic quote from a tagged JSON request. +/// Evaluates a canonical AMM quote, discovery operation, or host adapter from tagged JSON. /// /// Returned JSON owns its memory and must be released with [`amm_client_free`]. /// diff --git a/programs/amm/client/src/intent.rs b/programs/amm/client/src/intent.rs index 652a005b..32d197a4 100644 --- a/programs/amm/client/src/intent.rs +++ b/programs/amm/client/src/intent.rs @@ -15,6 +15,14 @@ use nssa_core::account::AccountId; /// One whole unit in the Q64.64 price representation used by the AMM. pub const Q64_64_ONE: u128 = 1_u128 << 64; +/// Largest token decimal count accepted by human-price conversion. +/// +/// One whole token at a larger decimal count cannot fit in the protocol's `u128` raw amount. +pub const MAX_TOKEN_DECIMALS: u8 = 38; + +/// Largest fractional precision accepted for either side of a human price ratio. +pub const MAX_HUMAN_PRICE_FRACTIONAL_DIGITS: u8 = 38; + /// Failure while turning a caller intent into executable AMM amounts. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] @@ -23,6 +31,19 @@ pub enum IntentError { IdenticalTokenDefinitions, /// A Q64.64 desired price must be nonzero. ZeroDesiredPrice, + /// One side of a human price ratio is not an unsigned decimal amount. + InvalidHumanPriceAmount { field: &'static str }, + /// One side of a human price ratio is zero. + ZeroHumanPriceAmount { field: &'static str }, + /// One side of a human price ratio has unsupported fractional precision. + HumanPricePrecisionOutOfRange { + field: &'static str, + precision: usize, + }, + /// Token metadata reports a decimal count outside the protocol amount range. + TokenDecimalsOutOfRange { field: &'static str, decimals: u8 }, + /// A positive human price is smaller than the least positive Q64.64 value. + HumanPriceUnderflow, /// An edited token amount must be nonzero. ZeroEditedAmount, /// A widened calculation produced a result outside the chain's `u128` amount range. @@ -47,6 +68,11 @@ impl IntentError { match self { Self::IdenticalTokenDefinitions => "identical_token_definitions", Self::ZeroDesiredPrice => "zero_desired_price", + Self::InvalidHumanPriceAmount { .. } => "invalid_human_price_amount", + Self::ZeroHumanPriceAmount { .. } => "zero_human_price_amount", + Self::HumanPricePrecisionOutOfRange { .. } => "human_price_precision_out_of_range", + Self::TokenDecimalsOutOfRange { .. } => "token_decimals_out_of_range", + Self::HumanPriceUnderflow => "human_price_underflow", Self::ZeroEditedAmount => "zero_edited_amount", Self::ArithmeticOverflow { .. } => "intent_arithmetic_overflow", Self::SpotPriceMismatch { .. } => "spot_price_mismatch", @@ -64,6 +90,23 @@ impl fmt::Display for IntentError { formatter.write_str("pool token definitions must be distinct") } Self::ZeroDesiredPrice => formatter.write_str("desired Q64.64 price must be nonzero"), + Self::InvalidHumanPriceAmount { field } => { + write!(formatter, "{field} must be an unsigned decimal amount") + } + Self::ZeroHumanPriceAmount { field } => { + write!(formatter, "{field} must be greater than zero") + } + Self::HumanPricePrecisionOutOfRange { field, precision } => write!( + formatter, + "{field} has {precision} fractional digits; maximum is {MAX_HUMAN_PRICE_FRACTIONAL_DIGITS}" + ), + Self::TokenDecimalsOutOfRange { field, decimals } => write!( + formatter, + "{field} is {decimals}; maximum is {MAX_TOKEN_DECIMALS}" + ), + Self::HumanPriceUnderflow => { + formatter.write_str("human price is below the Q64.64 precision range") + } Self::ZeroEditedAmount => formatter.write_str("edited token amount must be nonzero"), Self::ArithmeticOverflow { operation } => { write!(formatter, "{operation} exceeds the u128 amount range") @@ -162,6 +205,149 @@ impl PreparedCallerOpeningPair { } } +#[derive(Clone, Copy)] +struct ParsedHumanAmount { + mantissa: u128, + fractional_digits: u8, +} + +/// Converts an exact human token ratio into the pool's canonical raw Q64.64 price. +/// +/// `first_amount` units of the caller's first token are declared equal in value to +/// `second_amount` units of the second token. The token IDs select canonical stored A/B order; +/// callers do not invert the ratio when their display order is reversed. Token decimal counts +/// convert the human ratio into raw-unit reserve B per raw-unit reserve A. Calculation uses integer +/// arithmetic and floors once at Q64.64 conversion. +pub fn human_price_ratio_to_q64_64( + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + first_amount: &str, + second_amount: &str, + first_token_decimals: u8, + second_token_decimals: u8, +) -> Result { + let Some((stored_a_id, _)) = + canonical_token_pair(first_token_definition_id, second_token_definition_id) + else { + return Err(IntentError::IdenticalTokenDefinitions); + }; + validate_token_decimals("firstTokenDecimals", first_token_decimals)?; + validate_token_decimals("secondTokenDecimals", second_token_decimals)?; + let first = parse_human_price_amount(first_amount, "firstAmount")?; + let second = parse_human_price_amount(second_amount, "secondAmount")?; + + let (base, quote, base_decimals, quote_decimals) = if first_token_definition_id == stored_a_id { + (first, second, first_token_decimals, second_token_decimals) + } else { + (second, first, second_token_decimals, first_token_decimals) + }; + let numerator_exponent = u16::from(quote_decimals) + .checked_add(u16::from(base.fractional_digits)) + .ok_or(IntentError::ArithmeticOverflow { + operation: "human price numerator exponent", + })?; + let denominator_exponent = u16::from(base_decimals) + .checked_add(u16::from(quote.fractional_digits)) + .ok_or(IntentError::ArithmeticOverflow { + operation: "human price denominator exponent", + })?; + let (numerator_exponent, denominator_exponent) = if numerator_exponent >= denominator_exponent { + ( + numerator_exponent.checked_sub(denominator_exponent).ok_or( + IntentError::ArithmeticOverflow { + operation: "human price exponent reduction", + }, + )?, + 0, + ) + } else { + ( + 0, + denominator_exponent.checked_sub(numerator_exponent).ok_or( + IntentError::ArithmeticOverflow { + operation: "human price exponent reduction", + }, + )?, + ) + }; + + let numerator = U512::from(quote.mantissa) + .checked_mul(U512::from(Q64_64_ONE)) + .and_then(|value| value.checked_mul(pow10(numerator_exponent)?)) + .ok_or(IntentError::ArithmeticOverflow { + operation: "human price numerator", + })?; + let denominator = U512::from(base.mantissa) + .checked_mul( + pow10(denominator_exponent).ok_or(IntentError::ArithmeticOverflow { + operation: "human price denominator power", + })?, + ) + .ok_or(IntentError::ArithmeticOverflow { + operation: "human price denominator", + })?; + let converted = numerator + .checked_div(denominator) + .ok_or(IntentError::ArithmeticOverflow { + operation: "human price division", + })?; + if converted == U512::ZERO { + return Err(IntentError::HumanPriceUnderflow); + } + u128::try_from(converted).map_err(|_| IntentError::ArithmeticOverflow { + operation: "human Q64.64 price", + }) +} + +fn validate_token_decimals(field: &'static str, decimals: u8) -> Result<(), IntentError> { + if decimals > MAX_TOKEN_DECIMALS { + Err(IntentError::TokenDecimalsOutOfRange { field, decimals }) + } else { + Ok(()) + } +} + +fn parse_human_price_amount( + value: &str, + field: &'static str, +) -> Result { + let (whole, fraction) = value.split_once('.').map_or((value, ""), |parts| parts); + if whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fraction.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(IntentError::InvalidHumanPriceAmount { field }); + } + if fraction.len() > usize::from(MAX_HUMAN_PRICE_FRACTIONAL_DIGITS) { + return Err(IntentError::HumanPricePrecisionOutOfRange { + field, + precision: fraction.len(), + }); + } + + let mut digits = String::from(whole); + digits.push_str(fraction); + let mantissa = digits + .parse::() + .map_err(|_| IntentError::InvalidHumanPriceAmount { field })?; + if mantissa == 0 { + return Err(IntentError::ZeroHumanPriceAmount { field }); + } + let fractional_digits = + u8::try_from(fraction.len()).map_err(|_| IntentError::HumanPricePrecisionOutOfRange { + field, + precision: fraction.len(), + })?; + Ok(ParsedHumanAmount { + mantissa, + fractional_digits, + }) +} + +fn pow10(exponent: u16) -> Option { + (0..exponent).try_fold(U512::ONE, |value, _| value.checked_mul(U512::from(10_u8))) +} + /// Prepares an opening pair without requiring a caller to reproduce canonical token ordering. pub fn prepare_caller_opening_pair( first_token_definition_id: AccountId, diff --git a/programs/amm/client/src/lib.rs b/programs/amm/client/src/lib.rs index d8b9724e..49ec411e 100644 --- a/programs/amm/client/src/lib.rs +++ b/programs/amm/client/src/lib.rs @@ -6,6 +6,7 @@ mod ffi; pub mod intent; pub mod plan; pub mod quote; +pub mod sequencer; pub mod slippage; pub mod transaction; pub mod wire; @@ -18,11 +19,12 @@ pub use discovery::{ pub use error::ClientError; pub use ffi::{amm_client_free, amm_client_plan, amm_client_quote}; pub use intent::{ - caller_amounts_to_stored, paired_amount_from_token_a, paired_amount_from_token_b, - pool_spot_change_bps, prepare_caller_opening_pair, prepare_minimum_opening_pair, - prepare_opening_from_token_a, prepare_opening_from_token_b, stored_amounts_to_caller, - validate_explicit_opening_pair, IntentError, OpeningLiquidityIntent, PreparedCallerOpeningPair, - PreparedOpeningPair, Q64_64_ONE, + caller_amounts_to_stored, human_price_ratio_to_q64_64, paired_amount_from_token_a, + paired_amount_from_token_b, pool_spot_change_bps, prepare_caller_opening_pair, + prepare_minimum_opening_pair, prepare_opening_from_token_a, prepare_opening_from_token_b, + stored_amounts_to_caller, validate_explicit_opening_pair, IntentError, OpeningLiquidityIntent, + PreparedCallerOpeningPair, PreparedOpeningPair, MAX_HUMAN_PRICE_FRACTIONAL_DIGITS, + MAX_TOKEN_DECIMALS, Q64_64_ONE, }; pub use plan::{ encode_instruction, plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, @@ -33,6 +35,8 @@ pub use plan::{ RemoveLiquidityPlanInput, SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, }; +pub use quote::AccountSnapshot; +pub use sequencer::{account_snapshot_from_sequencer_response, SequencerAccountError}; pub use slippage::{ maximum_guard_amount, minimum_guard_amount, prepare_add_liquidity, prepare_create_pool, prepare_remove_liquidity, prepare_swap_exact_input, prepare_swap_exact_output, diff --git a/programs/amm/client/src/sequencer.rs b/programs/amm/client/src/sequencer.rs new file mode 100644 index 00000000..511ef5ff --- /dev/null +++ b/programs/amm/client/src/sequencer.rs @@ -0,0 +1,99 @@ +//! Lossless adapters for raw sequencer account responses. + +use std::{error::Error, fmt}; + +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde::Deserialize; +use serde_json::Value; + +use crate::quote::AccountSnapshot; + +/// Failure while decoding a sequencer account response. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum SequencerAccountError { + /// Response was not valid sequencer account JSON. + InvalidResponse, + /// Sequencer returned an RPC error. + RpcError, + /// Sequencer returned no account result. + MissingAccount, + /// Account data exceeds the NSSA account-data limit. + AccountDataTooLarge, +} + +impl SequencerAccountError { + /// Stable machine-readable error code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidResponse => "invalid_sequencer_response", + Self::RpcError => "sequencer_account_error", + Self::MissingAccount => "sequencer_account_missing", + Self::AccountDataTooLarge => "account_data_too_large", + } + } +} + +impl fmt::Display for SequencerAccountError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidResponse => "sequencer account response is invalid", + Self::RpcError => "sequencer returned an account error", + Self::MissingAccount => "sequencer returned no account", + Self::AccountDataTooLarge => "sequencer account data is too large", + }) + } +} + +impl Error for SequencerAccountError {} + +#[derive(Deserialize)] +struct SequencerEnvelope { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct SequencerAccount { + program_owner: ProgramId, + balance: u128, + data: Vec, + nonce: u128, +} + +/// Decodes a raw `getAccount` JSON-RPC response without routing integer fields through a +/// JavaScript numeric value. +/// +/// `response` must be the original response text. Passing a JSON value already parsed by a host +/// with IEEE-754 numbers can lose balances or nonces above `2^53` before this function sees them. +pub fn account_snapshot_from_sequencer_response( + account_id: AccountId, + response: &str, +) -> Result { + let envelope: SequencerEnvelope = + serde_json::from_str(response).map_err(|_| SequencerAccountError::InvalidResponse)?; + if envelope.error.is_some() { + return Err(SequencerAccountError::RpcError); + } + let account = envelope + .result + .ok_or(SequencerAccountError::MissingAccount)?; + let data = + Data::try_from(account.data).map_err(|_| SequencerAccountError::AccountDataTooLarge)?; + + Ok(AccountSnapshot::new( + account_id, + Account { + program_owner: account.program_owner, + balance: account.balance, + data, + nonce: Nonce(account.nonce), + }, + )) +} diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs index fa545b03..98682c36 100644 --- a/programs/amm/client/src/wire.rs +++ b/programs/amm/client/src/wire.rs @@ -18,10 +18,11 @@ use serde::Deserialize; use serde_json::{json, Value}; use crate::{ + account_snapshot_from_sequencer_response, discovery::{self, CanonicalPair, PairReadManifest}, - plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, - plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input, - plan_swap_exact_output, plan_sync_reserves, plan_update_config, + human_price_ratio_to_q64_64, plan_add_liquidity, plan_create_oracle_price_account, + plan_create_pool, plan_create_price_observations, plan_initialize, plan_remove_liquidity, + plan_swap_exact_input, plan_swap_exact_output, plan_sync_reserves, plan_update_config, quote::{ self as client_quote, AccountSnapshot, ValidatedFungibleDefinition, ValidatedFungibleHolding, ValidatedPoolSnapshot, @@ -30,10 +31,10 @@ use crate::{ CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, IntentError, OpeningLiquidityIntent, PoolContext, PreparedAddLiquidity, PreparedCallerOpeningPair, PreparedCreatePool, PreparedOpeningPair, PreparedRemoveLiquidity, PreparedSwapExactInput, - PreparedSwapExactOutput, PreparedTransaction, RemoveLiquidityPlanInput, SlippageTolerance, - SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionError, - TransactionOperation, TransactionPlan, UpdateConfigPlanInput, WalletPrerequisites, - SLIPPAGE_BPS_DENOMINATOR, + PreparedSwapExactOutput, PreparedTransaction, RemoveLiquidityPlanInput, SequencerAccountError, + SlippageTolerance, SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput, + TransactionError, TransactionOperation, TransactionPlan, UpdateConfigPlanInput, + WalletPrerequisites, SLIPPAGE_BPS_DENOMINATOR, }; /// Version of the reusable AMM client JSON contract. @@ -90,6 +91,12 @@ impl From for WireError { } } +impl From for WireError { + fn from(error: SequencerAccountError) -> Self { + Self::new(error.code(), error.to_string()) + } +} + #[derive(Clone, Copy, Deserialize)] #[serde(try_from = "String")] struct ProgramIdInput(ProgramId); @@ -410,6 +417,25 @@ impl PoolInput { #[serde(tag = "operation", rename_all = "snake_case")] enum QuoteRequest { ProtocolConstants, + AccountSnapshotFromSequencerResponse { + #[serde(rename = "accountId")] + account_id: String, + response: String, + }, + HumanPriceRatioToQ64_64 { + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "firstAmount")] + first_amount: String, + #[serde(rename = "secondAmount")] + second_amount: String, + #[serde(rename = "firstTokenDecimals")] + first_token_decimals: String, + #[serde(rename = "secondTokenDecimals")] + second_token_decimals: String, + }, DeriveConfigId { #[serde(rename = "ammProgramId")] amm_program_id: ProgramIdInput, @@ -1275,7 +1301,7 @@ pub fn plan_json(value: Value) -> Result { }) } -/// Evaluates one reusable AMM economic quote from tagged JSON. +/// Evaluates one reusable AMM quote, discovery operation, or lossless host adapter from JSON. pub fn quote_json(value: Value) -> Result { validate_wire_schema(&value)?; let request: QuoteRequest = serde_json::from_value(value) @@ -1290,6 +1316,33 @@ pub fn quote_json(value: Value) -> Result { .map(u128::to_string) .collect::>(), })), + QuoteRequest::AccountSnapshotFromSequencerResponse { + account_id: requested_account_id, + response, + } => { + let snapshot = account_snapshot_from_sequencer_response( + account_id(&requested_account_id, "accountId")?, + &response, + )?; + Ok(account_snapshot_json(&snapshot)) + } + QuoteRequest::HumanPriceRatioToQ64_64 { + first_token_definition_id, + second_token_definition_id, + first_amount, + second_amount, + first_token_decimals, + second_token_decimals, + } => Ok(json!({ + "priceQ64_64": human_price_ratio_to_q64_64( + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + &first_amount, + &second_amount, + decimal_u8(&first_token_decimals, "firstTokenDecimals")?, + decimal_u8(&second_token_decimals, "secondTokenDecimals")?, + )?.to_string(), + })), QuoteRequest::DeriveConfigId { amm_program_id } => Ok(json!({ "configId": discovery::derive_config_id(amm_program_id.into()).to_string(), })), @@ -1944,6 +1997,22 @@ fn pool_update_json(pool: PoolUpdate) -> Value { }) } +fn account_snapshot_json(snapshot: &AccountSnapshot) -> Value { + let account = snapshot.account(); + json!({ + "id": snapshot.account_id().to_string(), + "programOwner": program_id_hex(account.program_owner), + "balance": account.balance.to_string(), + "nonce": account.nonce.0.to_string(), + "data": account + .data + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + }) +} + fn amm_context_json(context: &AmmContext) -> Value { json!({ "ammProgramId": program_id_hex(context.amm_program_id), @@ -2307,6 +2376,10 @@ fn decimal_u64(value: &str, field: &str) -> Result { decimal(value, field) } +fn decimal_u8(value: &str, field: &str) -> Result { + decimal(value, field) +} + fn optional_decimal_u128(value: Option, field: &str) -> Result, WireError> { value .as_deref() diff --git a/programs/amm/client/tests/consumer_adapter_contract.rs b/programs/amm/client/tests/consumer_adapter_contract.rs new file mode 100644 index 00000000..2c6d763b --- /dev/null +++ b/programs/amm/client/tests/consumer_adapter_contract.rs @@ -0,0 +1,91 @@ +mod common; + +use amm_client::{ + account_snapshot_from_sequencer_response, human_price_ratio_to_q64_64, wire::quote_json, + Q64_64_ONE, +}; +use amm_core::canonical_token_pair; +use common::program_id_hex; +use nssa_core::account::AccountId; +use serde_json::json; + +const RAW_SEQUENCER_RESPONSE: &str = r#"{ + "jsonrpc":"2.0", + "id":1, + "result":{ + "program_owner":[1,2,3,4,5,6,7,8], + "balance":340282366920938463463374607431768211455, + "data":[0,255], + "nonce":9007199254740993 + } +}"#; + +#[test] +fn raw_sequencer_response_becomes_lossless_snapshot() { + let account_id = AccountId::new([7; 32]); + let snapshot = account_snapshot_from_sequencer_response(account_id, RAW_SEQUENCER_RESPONSE) + .expect("raw sequencer account must decode"); + + assert_eq!(snapshot.account_id(), account_id); + assert_eq!(snapshot.account().program_owner, [1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(snapshot.account().balance, u128::MAX); + assert_eq!(snapshot.account().nonce.0, 9_007_199_254_740_993); + assert_eq!(snapshot.account().data.as_ref(), &[0, 255]); + + let wire = quote_json(json!({ + "operation": "account_snapshot_from_sequencer_response", + "accountId": account_id.to_string(), + "response": RAW_SEQUENCER_RESPONSE, + })) + .expect("wire adapter must decode raw response text"); + assert_eq!(wire["id"], account_id.to_string()); + assert_eq!( + wire["programOwner"], + program_id_hex([1, 2, 3, 4, 5, 6, 7, 8]) + ); + assert_eq!(wire["balance"], u128::MAX.to_string()); + assert_eq!(wire["nonce"], "9007199254740993"); + assert_eq!(wire["data"], "00ff"); +} + +#[test] +fn human_price_conversion_handles_large_values_order_and_decimals() { + let first_id = AccountId::new([1; 32]); + let second_id = AccountId::new([2; 32]); + let (stored_a_id, stored_b_id) = + canonical_token_pair(first_id, second_id).expect("tokens are distinct"); + let amount_a = "9007199254740993"; + let amount_b = "18014398509481986"; + let expected = Q64_64_ONE + .checked_mul(2_000_000_000_000) + .expect("expected Q64.64 price fits"); + + let stored = human_price_ratio_to_q64_64(stored_a_id, stored_b_id, amount_a, amount_b, 6, 18) + .expect("stored-order price must convert"); + let reversed = human_price_ratio_to_q64_64(stored_b_id, stored_a_id, amount_b, amount_a, 18, 6) + .expect("reversed caller price must convert"); + + assert_eq!(stored, expected); + assert_eq!(reversed, expected); + + let inverse_decimal_scale = + human_price_ratio_to_q64_64(stored_a_id, stored_b_id, "1", "2", 18, 6) + .expect("negative decimal exponent must convert"); + let inverse_expected = Q64_64_ONE + .checked_mul(2) + .and_then(|value| value.checked_div(1_000_000_000_000)) + .expect("inverse decimal-scale expectation fits"); + assert_eq!(inverse_decimal_scale, inverse_expected); + + let wire = quote_json(json!({ + "operation": "human_price_ratio_to_q64_64", + "firstTokenDefinitionId": stored_b_id.to_string(), + "secondTokenDefinitionId": stored_a_id.to_string(), + "firstAmount": amount_b, + "secondAmount": amount_a, + "firstTokenDecimals": "18", + "secondTokenDecimals": "6", + })) + .expect("wire price conversion must use canonical stored order"); + assert_eq!(wire["priceQ64_64"], expected.to_string()); +} From 421e5a52dcdb497de45de9bef547294689e6d53f Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 23:22:32 -0300 Subject: [PATCH 7/8] fix(amm-client)!: align shared quote wire contract Use canonical ProgramId word arrays, include pool spot movement in standalone swap quotes, and keep quote commitments stable across deadline refreshes. BREAKING CHANGE: JSON ProgramId values now use eight u32 words. Convert hex or byte representations in host adapters. --- programs/amm/client/README.md | 11 +- programs/amm/client/docs/wire-api.md | 28 +-- programs/amm/client/include/amm_client.h | 5 +- programs/amm/client/src/transaction.rs | 144 +++++++++++++++- programs/amm/client/src/wire.rs | 112 ++++++------ programs/amm/client/tests/common/mod.rs | 10 +- .../client/tests/consumer_adapter_contract.rs | 4 +- programs/amm/client/tests/ffi_contract.rs | 27 +-- .../amm/client/tests/transaction_contract.rs | 8 +- .../tests/wire_discovery_intent_contract.rs | 21 ++- .../amm/client/tests/wire_prepare_contract.rs | 161 +++++++++++++++++- .../client/tests/wire_transaction_contract.rs | 10 +- 12 files changed, 411 insertions(+), 130 deletions(-) diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md index 55c0944e..cda8d086 100644 --- a/programs/amm/client/README.md +++ b/programs/amm/client/README.md @@ -75,9 +75,10 @@ Every call returns an owned JSON envelope. Release it exactly once with `amm_cli [`docs/wire-api.md`](docs/wire-api.md) for the complete transport contract. Raw `u128` and `u64` values cross JSON as decimal strings. Account IDs use canonical base58. -Program IDs use 64-character lowercase hexadecimal strings. Account data uses hexadecimal, and -encoded instruction words remain JSON `u32` numbers. No JavaScript `Number` conversion is required -for chain amounts or deadlines. Plan JSON also includes typed `instructionArgs`, derived directly -from the same `amm_core::Instruction` encoded in `instructionWords`. Only `amm_client_plan` accepts -the five snapshot-bound `prepare_*_transaction` operations. The quote entrypoint also exposes +Program IDs use exactly eight JSON `u32` words; hexadecimal and byte layouts are host-adapter-only. +Account data uses hexadecimal, and encoded instruction words remain JSON `u32` numbers. No +JavaScript `Number` conversion is required for chain amounts or deadlines. Plan JSON also includes +typed `instructionArgs`, derived directly from the same +`amm_core::Instruction` encoded in `instructionWords`. Only `amm_client_plan` accepts the five +snapshot-bound `prepare_*_transaction` operations. The quote entrypoint also exposes `account_snapshot_from_sequencer_response` and `human_price_ratio_to_q64_64` host adapters. diff --git a/programs/amm/client/docs/wire-api.md b/programs/amm/client/docs/wire-api.md index b0aa99d6..399bd938 100644 --- a/programs/amm/client/docs/wire-api.md +++ b/programs/amm/client/docs/wire-api.md @@ -14,9 +14,10 @@ Requests may include `"schema":"amm-client.v1"`. Schema-less requests remain acc compatibility. Every successful wire value and every C envelope identifies the response schema. All `u128` amounts, reserves, supplies, fees, nonces, and balances are unsigned decimal strings. -All `u64` windows and deadlines are also decimal strings. Program IDs are exactly 64 lowercase -hexadecimal characters: the 32 bytes formed by concatenating the eight `u32` words in -little-endian byte order. Signed ticks are decimal strings. Account IDs use canonical base58. +All `u64` windows and deadlines are also decimal strings. Program IDs are JSON arrays containing +exactly eight `u32` words in their canonical order. Hexadecimal and byte layouts are host-adapter +concerns and are neither accepted nor emitted by this shared wire API. Signed ticks are decimal +strings. Account IDs use canonical base58. Account `data` is an even-length hexadecimal string. ## Shared inputs @@ -25,9 +26,9 @@ Plan context: ```json { - "ammProgramId": "0000000000000000000000000000000000000000000000000000000000000000", - "tokenProgramId": "0000000000000000000000000000000000000000000000000000000000000000", - "twapOracleProgramId": "0000000000000000000000000000000000000000000000000000000000000000", + "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "tokenProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "twapOracleProgramId": [0, 0, 0, 0, 0, 0, 0, 0], "authority": "base58-account-id" } ``` @@ -54,7 +55,7 @@ Fetched account snapshot used by quotes: ```json { "id": "base58-account-id", - "programOwner": "0000000000000000000000000000000000000000000000000000000000000000", + "programOwner": [0, 0, 0, 0, 0, 0, 0, 0], "balance": "0", "nonce": "0", "data": "00ff" @@ -65,7 +66,7 @@ Existing-pool quote operations include these top-level state fields: ```json { - "ammProgramId": "0000000000000000000000000000000000000000000000000000000000000000", + "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], "config": { "...": "account snapshot" }, "snapshot": { "pool": { "...": "account snapshot" }, @@ -129,7 +130,7 @@ A successful plan value contains the following fields (`instructionWords` is abb "maxAmountToAddTokenB": "100", "deadline": "1900000000000" }, - "programId": "0000000000000000000000000000000000000000000000000000000000000000", + "programId": [0, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { "id": "base58-account-id", @@ -194,7 +195,8 @@ Quote values use these result shapes: - pool creation: `pool`, `lockedLiquidity`, `userLiquidity`; - add liquidity: `actualAmountA`, `actualAmountB`, `liquidityToMint`, `pool`; - remove liquidity: `withdrawAmountA`, `withdrawAmountB`, `liquidityToBurn`, `pool`; -- swaps: `direction`, `amountIn`, `effectiveAmountIn`, `feeAmount`, `amountOut`, `pool`; +- swaps: `direction`, `amountIn`, `effectiveAmountIn`, `feeAmount`, `amountOut`, `pool`, and + decimal-string `poolSpotChangeBps`; - reserve sync: `donatedAmountA`, `donatedAmountB`, `pool`; - oracle price: `baseAsset`, `quoteAsset`, `initialPriceQ64_64`, `windowDuration`; and - pair order: `order` (`stored` or `reversed`). @@ -202,6 +204,9 @@ Quote values use these result shapes: A `pool` result contains decimal-string `liquidityPoolSupply`, `reserveA`, `reserveB`, and `spotPriceQ64_64` fields. +`poolSpotChangeBps` is the exact directional movement of the pool's spot price from the pre-swap +snapshot to the returned quote. It is not execution-price impact. + ## Host adapters `account_snapshot_from_sequencer_response` accepts the original JSON-RPC response as a JSON string, @@ -295,7 +300,8 @@ Successful task output contains: `poolSpotChangeBps` is `null` for non-swap tasks. Add-liquidity funding requirements use the caller caps. Exact-output swap funding uses the plan's slippage-adjusted `maxAmountIn`. Hosts should refresh snapshots, prepare again, compare `quoteCommitment`, and submit only the refreshed -plan. +plan. A deadline-only refresh keeps the same `quoteCommitment`; the refreshed plan still carries +the deadline to submit. ## Prepared instruction arguments diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h index 484495e7..c09a9f4a 100644 --- a/programs/amm/client/include/amm_client.h +++ b/programs/amm/client/include/amm_client.h @@ -29,8 +29,9 @@ char *amm_client_quote(const char *request_json); /* * Raw u128, u64, and signed tick values are decimal JSON strings. Program IDs - * are 64-character lowercase hexadecimal strings. Instruction words are JSON - * u32 arrays. Account IDs use canonical base58 and account data is hexadecimal. + * are JSON arrays of eight u32 words. Hexadecimal and byte layouts are host-adapter-only. + * Instruction words are JSON u32 arrays. Account IDs use canonical base58 and account data is + * hexadecimal. * Requests may carry schema "amm-client.v1"; * schema-less legacy requests remain accepted. Responses use * {"schema":"amm-client.v1","ok":true,"value":...} or the same envelope diff --git a/programs/amm/client/src/transaction.rs b/programs/amm/client/src/transaction.rs index 2d55a77c..94d248bb 100644 --- a/programs/amm/client/src/transaction.rs +++ b/programs/amm/client/src/transaction.rs @@ -140,7 +140,10 @@ impl WalletPrerequisites { } } -/// SHA-256 commitment to the typed request, exact plan, and role-tagged account snapshots. +/// SHA-256 commitment to quote-relevant intent, guards, account selection, and snapshots. +/// +/// The transaction deadline remains part of the returned plan, but is deliberately not committed: +/// hosts may refresh their deadline window without invalidating an otherwise unchanged quote. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct QuoteCommitment([u8; 32]); @@ -443,7 +446,7 @@ pub fn prepare_create_pool_transaction( account: "AMM pool", expected: "uninitialized pool lifecycle", } - .into()) + .into()); } }; let first_definition = missing.first_token_definition(); @@ -885,8 +888,7 @@ impl PreparedTransaction { unique_account_ids.push(account_id); } - let instruction_words = plan - .instruction_data() + plan.instruction_data() .map_err(|_| TransactionError::InstructionEncoding)?; let plan_accounts = plan .accounts() @@ -904,11 +906,10 @@ impl PreparedTransaction { operation, intent, program_id: plan.program_id(), - instruction_words, + guards: CommitmentGuards::from(plan.instruction()), plan_accounts, sources, caller_amounts, - deadline, }; let words = risc0_zkvm::serde::to_vec(&payload) .map_err(|_| TransactionError::CommitmentEncoding)?; @@ -946,11 +947,138 @@ struct PreparedCommitmentPayload { operation: TransactionOperation, intent: TransactionIntent, program_id: ProgramId, - instruction_words: Vec, + guards: CommitmentGuards, plan_accounts: Vec, sources: Vec, caller_amounts: CallerAmounts, - deadline: u64, +} + +/// Instruction fields generated from a quote that affect its economic or account-selection +/// meaning. Deadlines are intentionally excluded because they are host transaction policy. +#[derive(Serialize)] +enum CommitmentGuards { + Initialize { + token_program_id: ProgramId, + twap_oracle_program_id: ProgramId, + authority: AccountId, + }, + UpdateConfig { + token_program_id: Option, + twap_oracle_program_id: Option, + new_authority: Option, + }, + CreatePriceObservations { + window_duration: u64, + }, + CreateOraclePriceAccount { + window_duration: u64, + }, + CreatePool { + token_a_amount: u128, + token_b_amount: u128, + fees: u128, + }, + AddLiquidity { + min_amount_liquidity: u128, + max_amount_to_add_token_a: u128, + max_amount_to_add_token_b: u128, + }, + RemoveLiquidity { + remove_liquidity_amount: u128, + min_amount_to_remove_token_a: u128, + min_amount_to_remove_token_b: u128, + }, + SwapExactInput { + swap_amount_in: u128, + min_amount_out: u128, + }, + SwapExactOutput { + exact_amount_out: u128, + max_amount_in: u128, + }, + SyncReserves, +} + +impl From<&amm_core::Instruction> for CommitmentGuards { + fn from(instruction: &amm_core::Instruction) -> Self { + match instruction { + amm_core::Instruction::Initialize { + token_program_id, + twap_oracle_program_id, + authority, + } => Self::Initialize { + token_program_id: *token_program_id, + twap_oracle_program_id: *twap_oracle_program_id, + authority: *authority, + }, + amm_core::Instruction::UpdateConfig { + token_program_id, + twap_oracle_program_id, + new_authority, + } => Self::UpdateConfig { + token_program_id: *token_program_id, + twap_oracle_program_id: *twap_oracle_program_id, + new_authority: *new_authority, + }, + amm_core::Instruction::CreatePriceObservations { window_duration } => { + Self::CreatePriceObservations { + window_duration: *window_duration, + } + } + amm_core::Instruction::CreateOraclePriceAccount { window_duration } => { + Self::CreateOraclePriceAccount { + window_duration: *window_duration, + } + } + amm_core::Instruction::NewDefinition { + token_a_amount, + token_b_amount, + fees, + .. + } => Self::CreatePool { + token_a_amount: *token_a_amount, + token_b_amount: *token_b_amount, + fees: *fees, + }, + amm_core::Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + .. + } => Self::AddLiquidity { + min_amount_liquidity: *min_amount_liquidity, + max_amount_to_add_token_a: *max_amount_to_add_token_a, + max_amount_to_add_token_b: *max_amount_to_add_token_b, + }, + amm_core::Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + .. + } => Self::RemoveLiquidity { + remove_liquidity_amount: *remove_liquidity_amount, + min_amount_to_remove_token_a: *min_amount_to_remove_token_a, + min_amount_to_remove_token_b: *min_amount_to_remove_token_b, + }, + amm_core::Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + .. + } => Self::SwapExactInput { + swap_amount_in: *swap_amount_in, + min_amount_out: *min_amount_out, + }, + amm_core::Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + .. + } => Self::SwapExactOutput { + exact_amount_out: *exact_amount_out, + max_amount_in: *max_amount_in, + }, + amm_core::Instruction::SyncReserves => Self::SyncReserves, + } + } } #[derive(Serialize)] diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs index 98682c36..be6e5250 100644 --- a/programs/amm/client/src/wire.rs +++ b/programs/amm/client/src/wire.rs @@ -98,7 +98,7 @@ impl From for WireError { } #[derive(Clone, Copy, Deserialize)] -#[serde(try_from = "String")] +#[serde(transparent)] struct ProgramIdInput(ProgramId); impl From for ProgramId { @@ -107,14 +107,6 @@ impl From for ProgramId { } } -impl TryFrom for ProgramIdInput { - type Error = String; - - fn try_from(value: String) -> Result { - parse_program_id(&value).map(Self) - } -} - #[derive(Deserialize)] #[serde(tag = "operation", rename_all = "snake_case")] enum PlanRequest { @@ -1648,7 +1640,7 @@ pub fn quote_json(value: Value) -> Result { &user_output, decimal_u128(&amount_in, "amountIn")?, )?; - Ok(swap_quote_json(quote)) + swap_quote_with_pool_spot_change_json(snapshot.pool(), quote) } QuoteRequest::PrepareSwapExactInput { state, @@ -1673,7 +1665,7 @@ pub fn quote_json(value: Value) -> Result { decimal_u128(&amount_in, "amountIn")?, slippage_tolerance(&slippage_bps)?, )?; - Ok(prepared_swap_exact_input_json(prepared)) + prepared_swap_exact_input_json(snapshot.pool(), prepared) } QuoteRequest::SwapExactInput { state, @@ -1698,7 +1690,7 @@ pub fn quote_json(value: Value) -> Result { decimal_u128(&amount_in, "amountIn")?, decimal_u128(&minimum_amount_out, "minimumAmountOut")?, )?; - Ok(swap_quote_json(quote)) + swap_quote_with_pool_spot_change_json(snapshot.pool(), quote) } QuoteRequest::PreviewSwapExactOutput { state, @@ -1721,7 +1713,7 @@ pub fn quote_json(value: Value) -> Result { &user_output, decimal_u128(&exact_amount_out, "exactAmountOut")?, )?; - Ok(swap_quote_json(quote)) + swap_quote_with_pool_spot_change_json(snapshot.pool(), quote) } QuoteRequest::PrepareSwapExactOutput { state, @@ -1746,7 +1738,7 @@ pub fn quote_json(value: Value) -> Result { decimal_u128(&exact_amount_out, "exactAmountOut")?, slippage_tolerance(&slippage_bps)?, )?; - Ok(prepared_swap_exact_output_json(prepared)) + prepared_swap_exact_output_json(snapshot.pool(), prepared) } QuoteRequest::SwapExactOutput { state, @@ -1771,7 +1763,7 @@ pub fn quote_json(value: Value) -> Result { decimal_u128(&exact_amount_out, "exactAmountOut")?, decimal_u128(&maximum_amount_in, "maximumAmountIn")?, )?; - Ok(swap_quote_json(quote)) + swap_quote_with_pool_spot_change_json(snapshot.pool(), quote) } QuoteRequest::SyncReserves { state } => { let (_, snapshot) = state.validate()?; @@ -1818,7 +1810,7 @@ fn transaction_plan_json(plan: &TransactionPlan) -> Result { Ok(json!({ "instruction": plan.instruction_name(), "instructionArgs": instruction_args_json(plan.instruction()), - "programId": program_id_hex(plan.program_id()), + "programId": program_id_words(plan.program_id()), "accounts": accounts, "affectedAccountIds": plan .affected_account_ids() @@ -1836,8 +1828,8 @@ fn instruction_args_json(instruction: &Instruction) -> Value { twap_oracle_program_id, authority, } => json!({ - "tokenProgramId": program_id_hex(*token_program_id), - "twapOracleProgramId": program_id_hex(*twap_oracle_program_id), + "tokenProgramId": program_id_words(*token_program_id), + "twapOracleProgramId": program_id_words(*twap_oracle_program_id), "authority": authority.to_string(), }), Instruction::UpdateConfig { @@ -1845,8 +1837,8 @@ fn instruction_args_json(instruction: &Instruction) -> Value { twap_oracle_program_id, new_authority, } => json!({ - "tokenProgramId": token_program_id.map(program_id_hex), - "twapOracleProgramId": twap_oracle_program_id.map(program_id_hex), + "tokenProgramId": token_program_id.map(program_id_words), + "twapOracleProgramId": twap_oracle_program_id.map(program_id_words), "newAuthority": new_authority.map(|authority| authority.to_string()), }), Instruction::CreatePriceObservations { window_duration } @@ -2001,7 +1993,7 @@ fn account_snapshot_json(snapshot: &AccountSnapshot) -> Value { let account = snapshot.account(); json!({ "id": snapshot.account_id().to_string(), - "programOwner": program_id_hex(account.program_owner), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -2015,10 +2007,10 @@ fn account_snapshot_json(snapshot: &AccountSnapshot) -> Value { fn amm_context_json(context: &AmmContext) -> Value { json!({ - "ammProgramId": program_id_hex(context.amm_program_id), + "ammProgramId": program_id_words(context.amm_program_id), "configId": context.config_id().to_string(), - "tokenProgramId": program_id_hex(context.token_program_id()), - "twapOracleProgramId": program_id_hex(context.twap_oracle_program_id()), + "tokenProgramId": program_id_words(context.token_program_id()), + "twapOracleProgramId": program_id_words(context.twap_oracle_program_id()), "authority": context.config.authority.to_string(), }) } @@ -2289,24 +2281,49 @@ fn swap_quote_json(quote: SwapQuote) -> Value { }) } -fn prepared_swap_exact_input_json(prepared: PreparedSwapExactInput) -> Value { - json!({ - "quote": swap_quote_json(prepared.quote), +fn swap_quote_with_pool_spot_change_json( + before: &PoolDefinition, + quote: SwapQuote, +) -> Result { + let pool_spot_change_bps = crate::pool_spot_change_bps(before, "e)?; + let mut value = swap_quote_json(quote); + let Some(object) = value.as_object_mut() else { + return Err(WireError::new( + "response_serialization_failed", + "swap quote response must be a JSON object", + )); + }; + object.insert( + String::from("poolSpotChangeBps"), + Value::String(pool_spot_change_bps.to_string()), + ); + Ok(value) +} + +fn prepared_swap_exact_input_json( + before: &PoolDefinition, + prepared: PreparedSwapExactInput, +) -> Result { + Ok(json!({ + "quote": swap_quote_with_pool_spot_change_json(before, prepared.quote)?, "instructionArgs": { "swapAmountIn": prepared.swap_amount_in.to_string(), "minAmountOut": prepared.min_amount_out.to_string(), }, - }) + })) } -fn prepared_swap_exact_output_json(prepared: PreparedSwapExactOutput) -> Value { - json!({ - "quote": swap_quote_json(prepared.quote), +fn prepared_swap_exact_output_json( + before: &PoolDefinition, + prepared: PreparedSwapExactOutput, +) -> Result { + Ok(json!({ + "quote": swap_quote_with_pool_spot_change_json(before, prepared.quote)?, "instructionArgs": { "exactAmountOut": prepared.exact_amount_out.to_string(), "maxAmountIn": prepared.max_amount_in.to_string(), }, - }) + })) } fn sync_reserves_quote_json(quote: SyncReservesQuote) -> Value { @@ -2326,35 +2343,8 @@ fn oracle_price_quote_json(quote: OraclePriceAccountQuote) -> Value { }) } -fn parse_program_id(value: &str) -> Result { - if value.len() != 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) - { - return Err(String::from( - "program ID must be exactly 64 lowercase hexadecimal characters", - )); - } - - let bytes = hex_bytes(value, "program ID").map_err(|error| error.to_string())?; - let mut program_id = [0_u32; 8]; - for (word, bytes) in program_id.iter_mut().zip(bytes.chunks_exact(4)) { - let mut word_bytes = [0_u8; 4]; - word_bytes.copy_from_slice(bytes); - *word = u32::from_le_bytes(word_bytes); - } - Ok(program_id) -} - -fn program_id_hex(program_id: ProgramId) -> String { - let mut output = String::with_capacity(64); - for word in program_id { - for byte in word.to_le_bytes() { - output.push_str(&format!("{byte:02x}")); - } - } - output +const fn program_id_words(program_id: ProgramId) -> ProgramId { + program_id } fn account_id(value: &str, field: &str) -> Result { diff --git a/programs/amm/client/tests/common/mod.rs b/programs/amm/client/tests/common/mod.rs index e4dfc60f..51e5488a 100644 --- a/programs/amm/client/tests/common/mod.rs +++ b/programs/amm/client/tests/common/mod.rs @@ -1,11 +1,5 @@ use nssa_core::program::ProgramId; -pub fn program_id_hex(program_id: ProgramId) -> String { - let mut output = String::with_capacity(64); - for word in program_id { - for byte in word.to_le_bytes() { - output.push_str(&format!("{byte:02x}")); - } - } - output +pub const fn program_id_words(program_id: ProgramId) -> ProgramId { + program_id } diff --git a/programs/amm/client/tests/consumer_adapter_contract.rs b/programs/amm/client/tests/consumer_adapter_contract.rs index 2c6d763b..8dd5a8c6 100644 --- a/programs/amm/client/tests/consumer_adapter_contract.rs +++ b/programs/amm/client/tests/consumer_adapter_contract.rs @@ -5,7 +5,7 @@ use amm_client::{ Q64_64_ONE, }; use amm_core::canonical_token_pair; -use common::program_id_hex; +use common::program_id_words; use nssa_core::account::AccountId; use serde_json::json; @@ -41,7 +41,7 @@ fn raw_sequencer_response_becomes_lossless_snapshot() { assert_eq!(wire["id"], account_id.to_string()); assert_eq!( wire["programOwner"], - program_id_hex([1, 2, 3, 4, 5, 6, 7, 8]) + json!(program_id_words([1, 2, 3, 4, 5, 6, 7, 8])) ); assert_eq!(wire["balance"], u128::MAX.to_string()); assert_eq!(wire["nonce"], "9007199254740993"); diff --git a/programs/amm/client/tests/ffi_contract.rs b/programs/amm/client/tests/ffi_contract.rs index 72a31ee8..f59c4742 100644 --- a/programs/amm/client/tests/ffi_contract.rs +++ b/programs/amm/client/tests/ffi_contract.rs @@ -12,7 +12,7 @@ use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; -use common::program_id_hex; +use common::program_id_words; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -48,7 +48,7 @@ fn call_json(operation: Operation, request: &Value) -> Value { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": program_id_hex(account.program_owner), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": hex(account.data.as_ref()), @@ -151,13 +151,14 @@ fn protocol_constants_are_exposed_without_numeric_json_values() { } #[test] -fn program_ids_require_canonical_lowercase_hex_strings() { - let canonical = program_id_hex([0xabcdef01; 8]); +fn program_ids_require_exactly_eight_u32_words() { + let canonical = program_id_words([0xabcdef01; 8]); let authority = AccountId::new([44; 32]).to_string(); for invalid in [ - json!([1, 1, 1, 1, 1, 1, 1, 1]), - json!(canonical.to_uppercase()), + json!("0000000000000000000000000000000000000000000000000000000000000000"), + json!([1, 1, 1, 1, 1, 1, 1]), + json!([1, 1, 1, 1, 1, 1, 1, 4_294_967_296_u64]), ] { let response = call_json( amm_client_plan, @@ -188,9 +189,9 @@ fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() { &json!({ "operation": "create_price_observations", "context": { - "ammProgramId": program_id_hex(amm_program_id), - "tokenProgramId": program_id_hex(token_program_id), - "twapOracleProgramId": program_id_hex(twap_oracle_program_id), + "ammProgramId": program_id_words(amm_program_id), + "tokenProgramId": program_id_words(token_program_id), + "twapOracleProgramId": program_id_words(twap_oracle_program_id), "authority": authority.to_string(), }, "poolId": pool_id.to_string(), @@ -205,7 +206,7 @@ fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() { ); assert_eq!( response["value"]["programId"], - program_id_hex(amm_program_id) + json!(program_id_words(amm_program_id)) ); assert!(response["value"]["accounts"].is_array()); let words: Vec = serde_json::from_value(response["value"]["instructionWords"].clone()) @@ -263,7 +264,7 @@ fn successful_quote_preserves_u128_above_javascript_range_as_decimal() { amm_client_quote, &json!({ "operation": "create_pool", - "ammProgramId": program_id_hex(amm_program_id), + "ammProgramId": program_id_words(amm_program_id), "config": snapshot(compute_config_pda(amm_program_id), &config_account), "tokenADefinition": snapshot(token_a_id, &definition("A")), "tokenBDefinition": snapshot(token_b_id, &definition("B")), @@ -289,7 +290,7 @@ fn successful_quote_preserves_u128_above_javascript_range_as_decimal() { amm_client_quote, &json!({ "operation": "prepare_create_pool", - "ammProgramId": program_id_hex(amm_program_id), + "ammProgramId": program_id_words(amm_program_id), "config": snapshot(compute_config_pda(amm_program_id), &config_account), "tokenADefinition": snapshot(token_a_id, &definition("A")), "tokenBDefinition": snapshot(token_b_id, &definition("B")), @@ -342,7 +343,7 @@ fn swap_quote_rejects_unrelated_output_holding() { amm_client_quote, &json!({ "operation": "preview_swap_exact_input", - "ammProgramId": program_id_hex(amm_program_id), + "ammProgramId": program_id_words(amm_program_id), "config": snapshot( compute_config_pda(amm_program_id), &account(amm_program_id, Data::from(&config)), diff --git a/programs/amm/client/tests/transaction_contract.rs b/programs/amm/client/tests/transaction_contract.rs index 0874efba..0df50217 100644 --- a/programs/amm/client/tests/transaction_contract.rs +++ b/programs/amm/client/tests/transaction_contract.rs @@ -481,7 +481,7 @@ fn exact_output_requires_funding_through_its_maximum_input_guard() { } #[test] -fn commitment_is_stable_and_changes_with_bound_snapshot_or_deadline() { +fn commitment_is_stable_across_deadline_refresh_and_changes_with_bound_snapshot() { let fixture = Fixture::new(); let missing = MissingPairFixture::new(&fixture); let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); @@ -522,10 +522,14 @@ fn commitment_is_stable_and_changes_with_bound_snapshot_or_deadline() { )); let changed_deadline = prepare(&fixture.caller_first_holding, DEADLINE + 1); - assert_ne!( + assert_eq!( first.quote_commitment(), changed_deadline.quote_commitment() ); + assert_ne!( + first.plan().instruction_data(), + changed_deadline.plan().instruction_data() + ); } #[test] diff --git a/programs/amm/client/tests/wire_discovery_intent_contract.rs b/programs/amm/client/tests/wire_discovery_intent_contract.rs index 0420a444..d8e4ae2f 100644 --- a/programs/amm/client/tests/wire_discovery_intent_contract.rs +++ b/programs/amm/client/tests/wire_discovery_intent_contract.rs @@ -6,7 +6,7 @@ use amm_core::{ compute_vault_pda, AmmConfig, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; -use common::program_id_hex; +use common::program_id_words; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -111,7 +111,7 @@ impl PairIds { fn inspect_request(&self, snapshots: Value) -> Value { json!({ "operation": "inspect_pair", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": config_snapshot(), "firstTokenDefinitionId": self.first_token_id.to_string(), "secondTokenDefinitionId": self.second_token_id.to_string(), @@ -205,7 +205,7 @@ impl PairIds { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": program_id_hex(account.program_owner), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -232,7 +232,7 @@ fn discovery_operations_return_exact_string_account_ids() { let config_id = quote_json(json!({ "operation": "derive_config_id", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), })) .expect("legacy schema-less request remains accepted"); assert_eq!(config_id["schema"], WIRE_SCHEMA); @@ -245,19 +245,22 @@ fn discovery_operations_return_exact_string_account_ids() { let inspected = quote_json(json!({ "schema": WIRE_SCHEMA, "operation": "inspect_config", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": config.clone(), })) .expect("config must inspect"); assert_eq!(inspected["schema"], WIRE_SCHEMA); - assert_eq!(inspected["ammProgramId"], program_id_hex(AMM_PROGRAM_ID)); + assert_eq!( + inspected["ammProgramId"], + json!(program_id_words(AMM_PROGRAM_ID)) + ); assert_eq!( inspected["tokenProgramId"], - program_id_hex(TOKEN_PROGRAM_ID) + json!(program_id_words(TOKEN_PROGRAM_ID)) ); assert_eq!( inspected["twapOracleProgramId"], - program_id_hex(TWAP_ORACLE_PROGRAM_ID) + json!(program_id_words(TWAP_ORACLE_PROGRAM_ID)) ); assert_eq!(inspected["authority"], AccountId::new([9; 32]).to_string()); @@ -272,7 +275,7 @@ fn discovery_operations_return_exact_string_account_ids() { let manifest = quote_json(json!({ "operation": "derive_pair_read_manifest", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": config, "firstTokenDefinitionId": first_token_id.to_string(), "secondTokenDefinitionId": second_token_id.to_string(), diff --git a/programs/amm/client/tests/wire_prepare_contract.rs b/programs/amm/client/tests/wire_prepare_contract.rs index f4d2e688..a5e84f94 100644 --- a/programs/amm/client/tests/wire_prepare_contract.rs +++ b/programs/amm/client/tests/wire_prepare_contract.rs @@ -5,7 +5,7 @@ use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, AmmConfig, PoolDefinition, FEE_TIER_BPS_30, }; -use common::program_id_hex; +use common::program_id_words; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -29,7 +29,7 @@ fn account(program_owner: ProgramId, data: Data) -> Account { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": program_id_hex(account.program_owner), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -102,7 +102,7 @@ impl WireFixture { fees: FEE_TIER_BPS_30, }; let state = json!({ - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": config, "snapshot": { "pool": snapshot(pool_id, &account(AMM_PROGRAM_ID, Data::from(&pool))), @@ -164,7 +164,7 @@ fn prepare_wire_operations_return_lossless_instruction_args() { let create = quote_json(json!({ "operation": "prepare_create_pool", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": fixture.config.clone(), "tokenADefinition": snapshot(fixture.token_a_id, &definition(100_000, None)), "tokenBDefinition": snapshot(fixture.token_b_id, &definition(100_000, None)), @@ -264,6 +264,159 @@ fn prepare_wire_operations_return_lossless_instruction_args() { ); } +#[test] +fn standalone_swap_quotes_include_exact_pool_spot_change() { + let fixture = WireFixture::new(); + let input_holding = fixture.user_a.clone(); + let output_holding = fixture.user_b.clone(); + let input_token_definition_id = fixture.token_a_id.to_string(); + + let mut preview_exact_input = fixture.request("preview_swap_exact_input"); + insert( + &mut preview_exact_input, + "userInputHolding", + input_holding.clone(), + ); + insert( + &mut preview_exact_input, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut preview_exact_input, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut preview_exact_input, "amountIn", json!("100")); + let preview_exact_input = quote_json(preview_exact_input).expect("preview must quote"); + + let mut prepare_exact_input = fixture.request("prepare_swap_exact_input"); + insert( + &mut prepare_exact_input, + "userInputHolding", + input_holding.clone(), + ); + insert( + &mut prepare_exact_input, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut prepare_exact_input, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut prepare_exact_input, "amountIn", json!("100")); + insert(&mut prepare_exact_input, "slippageBps", json!("100")); + let prepare_exact_input = quote_json(prepare_exact_input).expect("preparation must quote"); + + let mut exact_input = fixture.request("swap_exact_input"); + insert(&mut exact_input, "userInputHolding", input_holding.clone()); + insert( + &mut exact_input, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut exact_input, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut exact_input, "amountIn", json!("100")); + insert(&mut exact_input, "minimumAmountOut", json!("1")); + let exact_input = quote_json(exact_input).expect("exact-input quote must succeed"); + + let mut preview_exact_output = fixture.request("preview_swap_exact_output"); + insert( + &mut preview_exact_output, + "userInputHolding", + input_holding.clone(), + ); + insert( + &mut preview_exact_output, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut preview_exact_output, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut preview_exact_output, "exactAmountOut", json!("45")); + let preview_exact_output = quote_json(preview_exact_output).expect("preview must quote"); + + let mut prepare_exact_output = fixture.request("prepare_swap_exact_output"); + insert( + &mut prepare_exact_output, + "userInputHolding", + input_holding.clone(), + ); + insert( + &mut prepare_exact_output, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut prepare_exact_output, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut prepare_exact_output, "exactAmountOut", json!("45")); + insert(&mut prepare_exact_output, "slippageBps", json!("100")); + let prepare_exact_output = + quote_json(prepare_exact_output).expect("preparation must quote exact output"); + + let mut exact_output = fixture.request("swap_exact_output"); + insert(&mut exact_output, "userInputHolding", input_holding); + insert(&mut exact_output, "userOutputHolding", output_holding); + insert( + &mut exact_output, + "inputTokenDefinitionId", + json!(input_token_definition_id), + ); + insert(&mut exact_output, "exactAmountOut", json!("45")); + insert(&mut exact_output, "maximumAmountIn", json!("100")); + let exact_output = quote_json(exact_output).expect("exact-output quote must succeed"); + + let top_level_results = [ + &preview_exact_input, + &exact_input, + &preview_exact_output, + &exact_output, + ]; + for result in top_level_results { + assert_eq!(result["poolSpotChangeBps"], "2087"); + } + for result in [&prepare_exact_input, &prepare_exact_output] { + assert_eq!(result["quote"]["poolSpotChangeBps"], "2087"); + } + + let large_amount = 1_u128 << 80; + let mut large_input = fixture.request("preview_swap_exact_input"); + insert( + &mut large_input, + "userInputHolding", + snapshot( + AccountId::new([20; 32]), + &holding(fixture.token_a_id, large_amount), + ), + ); + insert(&mut large_input, "userOutputHolding", fixture.user_b); + insert( + &mut large_input, + "inputTokenDefinitionId", + json!(fixture.token_a_id.to_string()), + ); + insert( + &mut large_input, + "amountIn", + json!(large_amount.to_string()), + ); + let large_input = quote_json(large_input).expect("large preview must quote"); + let large_movement = decimal(&large_input["poolSpotChangeBps"]); + assert!(large_movement > (1_u128 << 53)); +} + #[test] fn prepare_wire_rejects_out_of_range_slippage() { let fixture = WireFixture::new(); diff --git a/programs/amm/client/tests/wire_transaction_contract.rs b/programs/amm/client/tests/wire_transaction_contract.rs index f89020fc..5b743cc3 100644 --- a/programs/amm/client/tests/wire_transaction_contract.rs +++ b/programs/amm/client/tests/wire_transaction_contract.rs @@ -6,7 +6,7 @@ use amm_core::{ compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; -use common::program_id_hex; +use common::program_id_words; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -55,7 +55,7 @@ fn holding(definition_id: AccountId, balance: u128) -> Account { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": program_id_hex(account.program_owner), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -212,7 +212,7 @@ impl TransactionFixture { fn active_common(&self, operation: &str) -> Value { json!({ "operation": operation, - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": self.config.clone(), "snapshots": self.active_snapshots.clone(), "firstTokenDefinitionId": self.first_token_id.to_string(), @@ -229,7 +229,7 @@ impl TransactionFixture { fn swap_common(&self, operation: &str) -> Value { json!({ "operation": operation, - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": self.config.clone(), "snapshots": self.active_snapshots.clone(), "inputTokenDefinitionId": self.first_token_id.to_string(), @@ -326,7 +326,7 @@ fn five_transaction_operations_emit_exact_plans_and_task_artifacts() { let second_amount = LARGE.checked_mul(2).expect("test amount fits"); let create = plan_json(json!({ "operation": "prepare_create_pool_transaction", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": fixture.config.clone(), "snapshots": fixture.missing_snapshots.clone(), "firstTokenDefinitionId": fixture.first_token_id.to_string(), From 6ec1de74d74a090de0cb5257d507b5c77d6bfbf5 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Thu, 23 Jul 2026 02:30:45 -0300 Subject: [PATCH 8/8] refactor(amm-client)!: keep transport parsing in hosts Remove the generic sequencer-response adapter and reuse the transaction order mapper. BREAKING CHANGE: account_snapshot_from_sequencer_response and its quote JSON operation are removed. Hosts must normalize RPC responses into canonical account snapshots before calling amm_client. --- programs/amm/client/README.md | 7 +- programs/amm/client/docs/wire-api.md | 14 +-- programs/amm/client/include/amm_client.h | 4 +- programs/amm/client/src/lib.rs | 2 - programs/amm/client/src/sequencer.rs | 99 ------------------- programs/amm/client/src/transaction.rs | 69 ++++--------- programs/amm/client/src/wire.rs | 46 +-------- .../client/tests/consumer_adapter_contract.rs | 47 +-------- 8 files changed, 36 insertions(+), 252 deletions(-) delete mode 100644 programs/amm/client/src/sequencer.rs diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md index cda8d086..183b451b 100644 --- a/programs/amm/client/README.md +++ b/programs/amm/client/README.md @@ -17,8 +17,6 @@ adapter responsibilities. - `intent` prepares canonical opening amounts and caller/stored order mappings with integer-only protocol math. It also converts exact human price ratios and token decimals into stored-order Q64.64 prices without floating point. -- `sequencer` decodes original `getAccount` response text directly into lossless - `AccountSnapshot` values, preserving integer fields above `2^53`. - `slippage` converts validated quotes into integer-only instruction guards. Minimum guards round down, maximum guards round up, and checked overflow returns a typed error. - `plan` covers all ten guest instructions and returns the canonical instruction plus ordered @@ -80,5 +78,6 @@ Account data uses hexadecimal, and encoded instruction words remain JSON `u32` n JavaScript `Number` conversion is required for chain amounts or deadlines. Plan JSON also includes typed `instructionArgs`, derived directly from the same `amm_core::Instruction` encoded in `instructionWords`. Only `amm_client_plan` accepts the five -snapshot-bound `prepare_*_transaction` operations. The quote entrypoint also exposes -`account_snapshot_from_sequencer_response` and `human_price_ratio_to_q64_64` host adapters. +snapshot-bound `prepare_*_transaction` operations. The quote entrypoint exposes +`human_price_ratio_to_q64_64`; hosts normalize RPC responses into canonical snapshots before +calling the client. diff --git a/programs/amm/client/docs/wire-api.md b/programs/amm/client/docs/wire-api.md index 399bd938..e54090c1 100644 --- a/programs/amm/client/docs/wire-api.md +++ b/programs/amm/client/docs/wire-api.md @@ -160,7 +160,6 @@ shown in this table and the sections below. | `operation` | Additional fields | |---|---| | `protocol_constants` | none; returns decimal-string `minimumLiquidity`, `feeBpsDenominator`, `slippageBpsDenominator`, and `supportedFeeTiers` | -| `account_snapshot_from_sequencer_response` | canonical base58 `accountId`, original `getAccount` response text in `response` | | `human_price_ratio_to_q64_64` | caller-ordered token IDs, `firstAmount`, `secondAmount`, and decimal-string `firstTokenDecimals`/`secondTokenDecimals` | | `derive_config_id` | `ammProgramId` | | `inspect_config` | `ammProgramId`, raw `config` snapshot | @@ -209,11 +208,9 @@ snapshot to the returned quote. It is not execution-price impact. ## Host adapters -`account_snapshot_from_sequencer_response` accepts the original JSON-RPC response as a JSON string, -not a host-parsed object. It decodes sequencer numeric literals directly as Rust `u128` values and -returns the standard snapshot fields: `id`, `programOwner`, `balance`, `nonce`, and `data`. This -preserves balances and nonces above `2^53`. Do not route the response through a JavaScript or QML -numeric value first. +Hosts normalize sequencer or wallet responses into the canonical snapshot fields before calling the +client: base58 `id`, eight-word `programOwner`, decimal-string `balance` and `nonce`, and +hexadecimal `data`. Keep raw RPC parsing and wallet-specific representations outside AMM. `human_price_ratio_to_q64_64` declares that `firstAmount` human units of the first token equal `secondAmount` human units of the second token. Amounts are unsigned decimal text and may contain @@ -337,9 +334,8 @@ Every failure uses `{ "code": "...", "message": "..." }`. `code` is the stable machine-readable contract; `message` is diagnostic text. JSON adapter failures return `invalid_request` or `unsupported_schema`. The C envelope additionally returns `null_request`, `invalid_utf8`, `invalid_json`, `response_serialization_failed`, or `response_contains_nul` for -boundary failures. Sequencer adapters return `invalid_sequencer_response`, -`sequencer_account_error`, `sequencer_account_missing`, or `account_data_too_large`. Human-price -conversion uses the stable `IntentError` codes documented by the Rust API. +boundary failures. Human-price conversion uses the stable `IntentError` codes documented by the +Rust API. No request performs network I/O or checks an ImageID, release version, compatibility manifest, or program allowlist. Deployment configuration is expected to select the corresponding AMM build. diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h index c09a9f4a..e0c8a56f 100644 --- a/programs/amm/client/include/amm_client.h +++ b/programs/amm/client/include/amm_client.h @@ -19,8 +19,8 @@ char *amm_client_plan(const char *request_json); * Accepts a tagged UTF-8 JSON request and returns an owned UTF-8 JSON envelope. * Supported operation tags include protocol constants; config and pair discovery; * pair inspection; caller-order opening preparation; economic quote/preparation - * operations; reserve synchronization; oracle-price initialization; raw sequencer - * account normalization; and human-price Q64.64 conversion. + * operations; reserve synchronization; oracle-price initialization; and human-price Q64.64 + * conversion. Hosts normalize RPC and wallet responses before calling this API. * Snapshot-bound prepare_*_transaction operations belong to amm_client_plan. * See docs/wire-api.md for fields. * Release the result with amm_client_free. diff --git a/programs/amm/client/src/lib.rs b/programs/amm/client/src/lib.rs index 49ec411e..0aad0f78 100644 --- a/programs/amm/client/src/lib.rs +++ b/programs/amm/client/src/lib.rs @@ -6,7 +6,6 @@ mod ffi; pub mod intent; pub mod plan; pub mod quote; -pub mod sequencer; pub mod slippage; pub mod transaction; pub mod wire; @@ -36,7 +35,6 @@ pub use plan::{ SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, }; pub use quote::AccountSnapshot; -pub use sequencer::{account_snapshot_from_sequencer_response, SequencerAccountError}; pub use slippage::{ maximum_guard_amount, minimum_guard_amount, prepare_add_liquidity, prepare_create_pool, prepare_remove_liquidity, prepare_swap_exact_input, prepare_swap_exact_output, diff --git a/programs/amm/client/src/sequencer.rs b/programs/amm/client/src/sequencer.rs deleted file mode 100644 index 511ef5ff..00000000 --- a/programs/amm/client/src/sequencer.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Lossless adapters for raw sequencer account responses. - -use std::{error::Error, fmt}; - -use nssa_core::{ - account::{Account, AccountId, Data, Nonce}, - program::ProgramId, -}; -use serde::Deserialize; -use serde_json::Value; - -use crate::quote::AccountSnapshot; - -/// Failure while decoding a sequencer account response. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum SequencerAccountError { - /// Response was not valid sequencer account JSON. - InvalidResponse, - /// Sequencer returned an RPC error. - RpcError, - /// Sequencer returned no account result. - MissingAccount, - /// Account data exceeds the NSSA account-data limit. - AccountDataTooLarge, -} - -impl SequencerAccountError { - /// Stable machine-readable error code. - #[must_use] - pub const fn code(self) -> &'static str { - match self { - Self::InvalidResponse => "invalid_sequencer_response", - Self::RpcError => "sequencer_account_error", - Self::MissingAccount => "sequencer_account_missing", - Self::AccountDataTooLarge => "account_data_too_large", - } - } -} - -impl fmt::Display for SequencerAccountError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - Self::InvalidResponse => "sequencer account response is invalid", - Self::RpcError => "sequencer returned an account error", - Self::MissingAccount => "sequencer returned no account", - Self::AccountDataTooLarge => "sequencer account data is too large", - }) - } -} - -impl Error for SequencerAccountError {} - -#[derive(Deserialize)] -struct SequencerEnvelope { - #[serde(default)] - result: Option, - #[serde(default)] - error: Option, -} - -#[derive(Deserialize)] -struct SequencerAccount { - program_owner: ProgramId, - balance: u128, - data: Vec, - nonce: u128, -} - -/// Decodes a raw `getAccount` JSON-RPC response without routing integer fields through a -/// JavaScript numeric value. -/// -/// `response` must be the original response text. Passing a JSON value already parsed by a host -/// with IEEE-754 numbers can lose balances or nonces above `2^53` before this function sees them. -pub fn account_snapshot_from_sequencer_response( - account_id: AccountId, - response: &str, -) -> Result { - let envelope: SequencerEnvelope = - serde_json::from_str(response).map_err(|_| SequencerAccountError::InvalidResponse)?; - if envelope.error.is_some() { - return Err(SequencerAccountError::RpcError); - } - let account = envelope - .result - .ok_or(SequencerAccountError::MissingAccount)?; - let data = - Data::try_from(account.data).map_err(|_| SequencerAccountError::AccountDataTooLarge)?; - - Ok(AccountSnapshot::new( - account_id, - Account { - program_owner: account.program_owner, - balance: account.balance, - data, - nonce: Nonce(account.nonce), - }, - )) -} diff --git a/programs/amm/client/src/transaction.rs b/programs/amm/client/src/transaction.rs index 94d248bb..6ccb04c8 100644 --- a/programs/amm/client/src/transaction.rs +++ b/programs/amm/client/src/transaction.rs @@ -464,21 +464,9 @@ pub fn prepare_create_pool_transaction( } else { PairOrder::Reversed }; - let (stored_a_definition, stored_b_definition, stored_a_holding, stored_b_holding) = match order - { - PairOrder::Stored => ( - &first_definition, - &second_definition, - &first_holding, - &second_holding, - ), - PairOrder::Reversed => ( - &second_definition, - &first_definition, - &second_holding, - &first_holding, - ), - }; + let (stored_a_definition, stored_b_definition) = + order_pair(order, first_definition, second_definition); + let (stored_a_holding, stored_b_holding) = order_pair(order, &first_holding, &second_holding); let (stored_a_amount, stored_b_amount) = order.amounts_to_stored(input.first_amount, input.second_amount); let prepared = prepare_create_pool( @@ -558,7 +546,11 @@ pub fn prepare_add_liquidity_transaction( let snapshot = active.pool(); let order = active.caller_order(); validate_expected_fee(snapshot, input.expected_fee_bps)?; - let (first_definition, second_definition) = caller_definitions(snapshot, order); + let (first_definition, second_definition) = order_pair( + order, + snapshot.token_a_definition(), + snapshot.token_b_definition(), + ); let first_holding = ValidatedFungibleHolding::new(&context, input.first_token_holding, first_definition)?; let second_holding = @@ -586,8 +578,7 @@ pub fn prepare_add_liquidity_transaction( snapshot.liquidity_definition().account_id(), "liquidity holding", )?; - let (stored_holding_a, stored_holding_b) = - stored_holdings(order, &first_holding, &second_holding); + let (stored_holding_a, stored_holding_b) = order_pair(order, &first_holding, &second_holding); let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?; let plan = plan_add_liquidity(AddLiquidityPlanInput { context: &context, @@ -648,7 +639,11 @@ pub fn prepare_remove_liquidity_transaction( let snapshot = active.pool(); let order = active.caller_order(); validate_expected_fee(snapshot, input.expected_fee_bps)?; - let (first_definition, second_definition) = caller_definitions(snapshot, order); + let (first_definition, second_definition) = order_pair( + order, + snapshot.token_a_definition(), + snapshot.token_b_definition(), + ); let first_fresh = validate_holding_destination( &context, input.first_token_holding, @@ -1206,16 +1201,6 @@ fn pair_sources( sources } -fn caller_definitions( - snapshot: &ValidatedPoolSnapshot, - order: PairOrder, -) -> (&ValidatedFungibleDefinition, &ValidatedFungibleDefinition) { - match order { - PairOrder::Stored => (snapshot.token_a_definition(), snapshot.token_b_definition()), - PairOrder::Reversed => (snapshot.token_b_definition(), snapshot.token_a_definition()), - } -} - fn validate_expected_fee( snapshot: &ValidatedPoolSnapshot, expected_fee_bps: Option, @@ -1229,17 +1214,6 @@ fn validate_expected_fee( Ok(()) } -fn stored_holdings<'a>( - order: PairOrder, - first: &'a ValidatedFungibleHolding, - second: &'a ValidatedFungibleHolding, -) -> (&'a ValidatedFungibleHolding, &'a ValidatedFungibleHolding) { - match order { - PairOrder::Stored => (first, second), - PairOrder::Reversed => (second, first), - } -} - fn order_pair(order: PairOrder, first: T, second: T) -> (T, T) { match order { PairOrder::Stored => (first, second), @@ -1252,14 +1226,13 @@ fn swap_definitions( input_definition_id: AccountId, output_definition_id: AccountId, ) -> Result<(&ValidatedFungibleDefinition, &ValidatedFungibleDefinition), ClientError> { - match amm_program::quote::pair_order( - snapshot.pool(), - input_definition_id, - output_definition_id, - )? { - PairOrder::Stored => Ok((snapshot.token_a_definition(), snapshot.token_b_definition())), - PairOrder::Reversed => Ok((snapshot.token_b_definition(), snapshot.token_a_definition())), - } + let order = + amm_program::quote::pair_order(snapshot.pool(), input_definition_id, output_definition_id)?; + Ok(order_pair( + order, + snapshot.token_a_definition(), + snapshot.token_b_definition(), + )) } fn validate_holding_destination( diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs index be6e5250..5b55a3eb 100644 --- a/programs/amm/client/src/wire.rs +++ b/programs/amm/client/src/wire.rs @@ -18,7 +18,6 @@ use serde::Deserialize; use serde_json::{json, Value}; use crate::{ - account_snapshot_from_sequencer_response, discovery::{self, CanonicalPair, PairReadManifest}, human_price_ratio_to_q64_64, plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, plan_create_price_observations, plan_initialize, plan_remove_liquidity, @@ -31,10 +30,10 @@ use crate::{ CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, IntentError, OpeningLiquidityIntent, PoolContext, PreparedAddLiquidity, PreparedCallerOpeningPair, PreparedCreatePool, PreparedOpeningPair, PreparedRemoveLiquidity, PreparedSwapExactInput, - PreparedSwapExactOutput, PreparedTransaction, RemoveLiquidityPlanInput, SequencerAccountError, - SlippageTolerance, SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput, - TransactionError, TransactionOperation, TransactionPlan, UpdateConfigPlanInput, - WalletPrerequisites, SLIPPAGE_BPS_DENOMINATOR, + PreparedSwapExactOutput, PreparedTransaction, RemoveLiquidityPlanInput, SlippageTolerance, + SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionError, + TransactionOperation, TransactionPlan, UpdateConfigPlanInput, WalletPrerequisites, + SLIPPAGE_BPS_DENOMINATOR, }; /// Version of the reusable AMM client JSON contract. @@ -91,12 +90,6 @@ impl From for WireError { } } -impl From for WireError { - fn from(error: SequencerAccountError) -> Self { - Self::new(error.code(), error.to_string()) - } -} - #[derive(Clone, Copy, Deserialize)] #[serde(transparent)] struct ProgramIdInput(ProgramId); @@ -409,11 +402,6 @@ impl PoolInput { #[serde(tag = "operation", rename_all = "snake_case")] enum QuoteRequest { ProtocolConstants, - AccountSnapshotFromSequencerResponse { - #[serde(rename = "accountId")] - account_id: String, - response: String, - }, HumanPriceRatioToQ64_64 { #[serde(rename = "firstTokenDefinitionId")] first_token_definition_id: String, @@ -1308,16 +1296,6 @@ pub fn quote_json(value: Value) -> Result { .map(u128::to_string) .collect::>(), })), - QuoteRequest::AccountSnapshotFromSequencerResponse { - account_id: requested_account_id, - response, - } => { - let snapshot = account_snapshot_from_sequencer_response( - account_id(&requested_account_id, "accountId")?, - &response, - )?; - Ok(account_snapshot_json(&snapshot)) - } QuoteRequest::HumanPriceRatioToQ64_64 { first_token_definition_id, second_token_definition_id, @@ -1989,22 +1967,6 @@ fn pool_update_json(pool: PoolUpdate) -> Value { }) } -fn account_snapshot_json(snapshot: &AccountSnapshot) -> Value { - let account = snapshot.account(); - json!({ - "id": snapshot.account_id().to_string(), - "programOwner": program_id_words(account.program_owner), - "balance": account.balance.to_string(), - "nonce": account.nonce.0.to_string(), - "data": account - .data - .as_ref() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(), - }) -} - fn amm_context_json(context: &AmmContext) -> Value { json!({ "ammProgramId": program_id_words(context.amm_program_id), diff --git a/programs/amm/client/tests/consumer_adapter_contract.rs b/programs/amm/client/tests/consumer_adapter_contract.rs index 8dd5a8c6..63b957cf 100644 --- a/programs/amm/client/tests/consumer_adapter_contract.rs +++ b/programs/amm/client/tests/consumer_adapter_contract.rs @@ -1,53 +1,8 @@ -mod common; - -use amm_client::{ - account_snapshot_from_sequencer_response, human_price_ratio_to_q64_64, wire::quote_json, - Q64_64_ONE, -}; +use amm_client::{human_price_ratio_to_q64_64, wire::quote_json, Q64_64_ONE}; use amm_core::canonical_token_pair; -use common::program_id_words; use nssa_core::account::AccountId; use serde_json::json; -const RAW_SEQUENCER_RESPONSE: &str = r#"{ - "jsonrpc":"2.0", - "id":1, - "result":{ - "program_owner":[1,2,3,4,5,6,7,8], - "balance":340282366920938463463374607431768211455, - "data":[0,255], - "nonce":9007199254740993 - } -}"#; - -#[test] -fn raw_sequencer_response_becomes_lossless_snapshot() { - let account_id = AccountId::new([7; 32]); - let snapshot = account_snapshot_from_sequencer_response(account_id, RAW_SEQUENCER_RESPONSE) - .expect("raw sequencer account must decode"); - - assert_eq!(snapshot.account_id(), account_id); - assert_eq!(snapshot.account().program_owner, [1, 2, 3, 4, 5, 6, 7, 8]); - assert_eq!(snapshot.account().balance, u128::MAX); - assert_eq!(snapshot.account().nonce.0, 9_007_199_254_740_993); - assert_eq!(snapshot.account().data.as_ref(), &[0, 255]); - - let wire = quote_json(json!({ - "operation": "account_snapshot_from_sequencer_response", - "accountId": account_id.to_string(), - "response": RAW_SEQUENCER_RESPONSE, - })) - .expect("wire adapter must decode raw response text"); - assert_eq!(wire["id"], account_id.to_string()); - assert_eq!( - wire["programOwner"], - json!(program_id_words([1, 2, 3, 4, 5, 6, 7, 8])) - ); - assert_eq!(wire["balance"], u128::MAX.to_string()); - assert_eq!(wire["nonce"], "9007199254740993"); - assert_eq!(wire["data"], "00ff"); -} - #[test] fn human_price_conversion_handles_large_values_order_and_decimals() { let first_id = AccountId::new([1; 32]);