From 513fea5fcb890cd18a454e54d6987cbeb1851a63 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:05:06 +0200 Subject: [PATCH] feat(modules/amm): add swap_exact_out_quote op and module swapExactOutQuote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side SwapExactOutput preview. The swap_exact_out_quote FFI op orients the pool reserves to the requested in/out direction, prices via the shared amm_core::swap_exact_out_amounts (so requiredIn matches the chain), and derives the slippage ceiling: { requiredInRaw, maxInRaw, priceImpactBps }. no_pool and output_exceeds_liquidity (amount_out >= reserve) are returned as errors. Read-only — no quoteHash; the on-chain max_amount_in is the real guard. The module swapExactOutQuote(tokenIn, tokenOut, amountOut, slippageBps) method derives the pool via the config-free pool_id op, reads it, and wraps the op in the { status, error, ... } envelope. Mirrors swapExactInQuote. Not yet consumed by the QML swap view, so nothing breaks. Lets the buy field stay editable when the SwapCard is rewired in a follow-up. --- modules/amm/ffi/include/amm_ffi.h | 2 + modules/amm/ffi/src/api/mod.rs | 7 +- modules/amm/ffi/src/api/request.rs | 12 ++ modules/amm/ffi/src/api/swap.rs | 169 +++++++++++++++++++++++++++- modules/amm/ffi/src/ffi.rs | 7 +- modules/amm/ffi/src/lib.rs | 9 +- modules/amm/src/amm_module_impl.cpp | 45 ++++++++ modules/amm/src/amm_module_impl.h | 13 +++ 8 files changed, 252 insertions(+), 12 deletions(-) diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index e6ccfe3..8be6b27 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -34,6 +34,8 @@ char *amm_pool_id(const char *request_json); char *amm_swap_exact_in_quote(const char *request_json); +char *amm_swap_exact_out_quote(const char *request_json); + char *amm_swap_plan(const char *request_json); char *amm_program_id(const char *request_json); diff --git a/modules/amm/ffi/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs index 47b15d6..c8af9ef 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -23,7 +23,7 @@ use std::{error::Error, fmt}; pub use request::{ ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInQuoteRequest, - SwapPairRequest, SwapPlanRequest, TokenIdsRequest, + SwapExactOutQuoteRequest, SwapPairRequest, SwapPlanRequest, TokenIdsRequest, }; use serde_json::Value; @@ -113,6 +113,11 @@ pub fn swap_exact_in_quote(request: SwapExactInQuoteRequest) -> AmmResult { swap::swap_exact_in_quote(request).map_err(Into::into) } +/// Prices a `SwapExactOutput`: required input, slippage ceiling, and price impact. +pub fn swap_exact_out_quote(request: SwapExactOutQuoteRequest) -> AmmResult { + swap::swap_exact_out_quote(request).map_err(Into::into) +} + /// Builds the `SwapExactInput` wallet submission for a token pair. pub fn swap_plan(request: SwapPlanRequest) -> AmmResult { swap::swap_plan(request).map_err(Into::into) diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index cfb98ce..de65d9b 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -79,6 +79,18 @@ pub struct SwapExactInQuoteRequest { pub pool_data: String, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SwapExactOutQuoteRequest { + pub token_in_id: String, + pub token_out_id: String, + pub amount_out_raw: String, + pub slippage_bps: u32, + /// Pool account data (hex Borsh `PoolDefinition`). Empty / undecodable ⇒ the + /// op returns the `no_pool` error. + pub pool_data: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PoolIdRequest { diff --git a/modules/amm/ffi/src/api/swap.rs b/modules/amm/ffi/src/api/swap.rs index c4f764b..655aa6c 100644 --- a/modules/amm/ffi/src/api/swap.rs +++ b/modules/amm/ffi/src/api/swap.rs @@ -4,8 +4,8 @@ //! `pair::derive_pair` so the swap path never re-derives seeds. use amm_core::{ - compute_pool_pda, mul_div_floor, price_impact_bps, swap_exact_in_amounts, PoolDefinition, - FEE_BPS_DENOMINATOR, + compute_pool_pda, mul_div_ceil, mul_div_floor, price_impact_bps, swap_exact_in_amounts, + swap_exact_out_amounts, PoolDefinition, FEE_BPS_DENOMINATOR, }; use nssa_core::account::AccountId; use risc0_binfmt::ProgramBinary; @@ -13,8 +13,8 @@ use serde_json::{json, Value}; use super::{ pair::{derive_pair, is_canonical_pair}, - PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapExactInQuoteRequest, SwapPairRequest, - SwapPlanRequest, + PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapExactInQuoteRequest, + SwapExactOutQuoteRequest, SwapPairRequest, SwapPlanRequest, }; use crate::account::{ account_id_from_hex, account_id_hex, decode_account, parse_program_id, program_id_bytes, @@ -181,6 +181,92 @@ pub(super) fn swap_exact_in_quote(request: SwapExactInQuoteRequest) -> Result Result { + let token_in = account_id_from_hex(&request.token_in_id, "token in id")?; + let token_out = account_id_from_hex(&request.token_out_id, "token out id")?; + if token_in == token_out { + return Err(String::from("same_token_pair")); + } + let amount_out = parse_u128(&request.amount_out_raw, "amountOutRaw")?; + if amount_out == 0 { + // The guest's exact_output_swap_logic rejects a zero output before any + // transfer, so a zero-output preview would claim an unexecutable quote + // (swap_exact_out_amounts would otherwise return a free (0, 0)). + return Err(String::from("amount_too_small")); + } + if u128::from(request.slippage_bps) >= FEE_BPS_DENOMINATOR { + return Err(String::from("invalid_slippage")); + } + + // Decode the pool; absent / undecodable / empty ⇒ nothing to swap against. + let pool = hex::decode(&request.pool_data) + .ok() + .and_then(|bytes| borsh::from_slice::(&bytes).ok()) + .filter(|pool| pool.liquidity_pool_supply != 0) + .ok_or_else(|| String::from("no_pool"))?; + + // Orient reserves: the sold token is the deposit (input) side, the bought + // token the withdraw (output) side. + let (reserve_in, reserve_out) = if token_in == pool.definition_token_a_id + && token_out == pool.definition_token_b_id + { + (pool.reserve_a, pool.reserve_b) + } else if token_in == pool.definition_token_b_id && token_out == pool.definition_token_a_id { + (pool.reserve_b, pool.reserve_a) + } else { + return Err(String::from("pair_mismatch")); + }; + // A pool with a zero reserve on either side has no liquidity to price against; + // swap_exact_out_amounts would otherwise round required_in to 0 for a positive + // output. Mirror swap_exact_in_quote and treat it as no_pool. + if reserve_in == 0 || reserve_out == 0 { + return Err(String::from("no_pool")); + } + + // Required input for the desired output (shared with amm_program::swap). None + // when the pool can't deliver that much (amount_out >= reserve_out). + let Some((_, required_in)) = + swap_exact_out_amounts(amount_out, reserve_in, reserve_out, pool.fees) + else { + return Err(String::from("output_exceeds_liquidity")); + }; + + // Slippage ceiling: the most the user will pay, rounded up so rounding never + // trips the on-chain max-in check. + let slippage_ceiling = FEE_BPS_DENOMINATOR + u128::from(request.slippage_bps); + let max_in = mul_div_ceil(required_in, slippage_ceiling, FEE_BPS_DENOMINATOR); + + // Price impact (display): how far the required input rises above the naive spot + // cost (reserve_in * amount_out / reserve_out), in bps (fee + curve combined). + let spot_in = mul_div_floor(reserve_in, amount_out, reserve_out); + let price_impact_bps = if spot_in == 0 { + 0 + } else { + u32::try_from(mul_div_floor( + required_in.saturating_sub(spot_in), + FEE_BPS_DENOMINATOR, + spot_in, + )) + .unwrap_or(u32::MAX) + }; + + Ok(json!({ + "requiredInRaw": required_in.to_string(), + "maxInRaw": max_in.to_string(), + "priceImpactBps": price_impact_bps, + })) +} + /// Builds the `SwapExactInput` submission for a pair: the fixed 8-account IDL /// order (vaults canonical, only the user's input holding signs) and the /// instruction words (`risc0_zkvm::serde` — the same encoding the guest @@ -358,7 +444,7 @@ mod tests { } #[test] - fn swap_quote_prices_via_shared_formula_and_orients() { + fn swap_exact_in_quote_prices_via_shared_formula_and_orients() { let def_a = AccountId::new([0xAA; 32]); let def_b = AccountId::new([0xBB; 32]); let pool = PoolDefinition { @@ -407,7 +493,7 @@ mod tests { } #[test] - fn swap_quote_no_pool_is_an_error() { + fn swap_exact_in_quote_no_pool_is_an_error() { let def_a = AccountId::new([0xAA; 32]); let def_b = AccountId::new([0xBB; 32]); let req = |pool_data: String| SwapExactInQuoteRequest { @@ -501,6 +587,77 @@ mod tests { ); } + #[test] + fn swap_exact_out_quote_requires_input_and_bounds_it() { + let def_a = AccountId::new([0xAA; 32]); + let def_b = AccountId::new([0xBB; 32]); + let pool = PoolDefinition { + definition_token_a_id: def_a, + definition_token_b_id: def_b, + liquidity_pool_supply: 1_000_000, + reserve_a: 1_000_000, + reserve_b: 2_000_000, + fees: 30, + ..Default::default() + }; + let req = |amount_out_raw: &str| SwapExactOutQuoteRequest { + token_in_id: account_id_hex(def_a), + token_out_id: account_id_hex(def_b), + amount_out_raw: amount_out_raw.into(), + slippage_bps: 50, + pool_data: pool_data_hex(&pool), + }; + + // Sell A to receive exactly 10_000 B. + let q = swap_exact_out_quote(req("10000")).unwrap(); + let (_, required_in) = swap_exact_out_amounts(10_000, 1_000_000, 2_000_000, 30).unwrap(); + assert_eq!(q["requiredInRaw"], required_in.to_string()); + // maxIn = required_in * (10000 + 50) / 10000, rounded up. + assert_eq!( + q["maxInRaw"], + (required_in * 10_050).div_ceil(10_000).to_string() + ); + assert!(q["priceImpactBps"].is_number()); + // Only the input-side results are echoed — no output/reserves. + assert!(q.get("expectedOutRaw").is_none()); + assert!(q.get("reserveInRaw").is_none()); + + // Zero requested output is rejected — the guest rejects exact_amount_out + // == 0, so a zero-output preview would claim an unexecutable quote. + assert_eq!( + swap_exact_out_quote(req("0")), + Err(String::from("amount_too_small")) + ); + + // Asking for the whole reserve (or more) is unfulfillable. + assert_eq!( + swap_exact_out_quote(req("2000000")), + Err(String::from("output_exceeds_liquidity")) + ); + + // A zero reserve on either side is no liquidity, not a free quote — it + // passes the non-zero-supply decode filter but must still surface no_pool. + let empty_side = PoolDefinition { + definition_token_a_id: def_a, + definition_token_b_id: def_b, + liquidity_pool_supply: 1, + reserve_a: 0, + reserve_b: 2_000_000, + fees: 30, + ..Default::default() + }; + assert_eq!( + swap_exact_out_quote(SwapExactOutQuoteRequest { + token_in_id: account_id_hex(def_a), + token_out_id: account_id_hex(def_b), + amount_out_raw: "10000".into(), + slippage_bps: 50, + pool_data: pool_data_hex(&empty_side), + }), + Err(String::from("no_pool")) + ); + } + #[test] fn pool_id_is_order_independent_and_matches_core() { let program = "00".repeat(32); diff --git a/modules/amm/ffi/src/ffi.rs b/modules/amm/ffi/src/ffi.rs index 16ebe33..1b627be 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -8,7 +8,7 @@ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInQuoteRequest, - SwapPairRequest, SwapPlanRequest, TokenIdsRequest, + SwapExactOutQuoteRequest, SwapPairRequest, SwapPlanRequest, TokenIdsRequest, }; #[derive(Serialize)] @@ -127,6 +127,11 @@ pub extern "C" fn amm_swap_exact_in_quote(request_json: *const c_char) -> *mut c call::(request_json, api::swap_exact_in_quote) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_swap_exact_out_quote(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::swap_exact_out_quote) +} + #[unsafe(no_mangle)] pub extern "C" fn amm_swap_plan(request_json: *const c_char) -> *mut c_char { call::(request_json, api::swap_plan) diff --git a/modules/amm/ffi/src/lib.rs b/modules/amm/ffi/src/lib.rs index 56a9a2f..81dba38 100644 --- a/modules/amm/ffi/src/lib.rs +++ b/modules/amm/ffi/src/lib.rs @@ -7,8 +7,9 @@ pub mod api; pub use api::{ config_id, context, pair_ids, plan, pool_id, program_id, quote, resolve_pool, - swap_exact_in_quote, swap_pair, swap_plan, token_ids, AccountRead, AmmApiError, AmmResponse, - AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, - PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, - SwapExactInQuoteRequest, SwapPairRequest, SwapPlanRequest, TokenIdsRequest, WalletAccount, + swap_exact_in_quote, swap_exact_out_quote, swap_pair, swap_plan, token_ids, AccountRead, + AmmApiError, AmmResponse, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, + PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, + ResolvePoolRequest, SwapExactInQuoteRequest, SwapExactOutQuoteRequest, SwapPairRequest, + SwapPlanRequest, TokenIdsRequest, WalletAccount, }; diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index dd0ea4c..92c1e73 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -535,6 +535,51 @@ LogosMap AmmModuleImpl::swapExactInQuote(const std::string& token_in_hex, return out; } +LogosMap AmmModuleImpl::swapExactOutQuote(const std::string& token_in_hex, + const std::string& token_out_hex, + const nlohmann::json& amount_out, + int64_t slippage_bps) { + auto error = [](const std::string& err) { + return LogosMap{{"status", "error"}, {"error", err}}; + }; + + std::string amount_out_decimal; + if (!jsonAmountToDecimal(amount_out, amount_out_decimal)) + return error("bad_amount"); + + const std::string amm_program_id = ammProgramId(); + if (amm_program_id.empty()) + return error("config_missing"); + + // Derive the pool id (config-free) and read the pool account; its raw data is + // handed to the pricing op. An absent account has no data → `no_pool`. + const FfiResult poolId = call(amm_pool_id, json{ + {"ammProgramId", amm_program_id}, + {"tokenInId", token_in_hex}, + {"tokenOutId", token_out_hex}, + }); + if (!poolId.ok) + return error(poolId.error.empty() ? "backend_error" : poolId.error); + const json pool = readPublicAccount(jStr(poolId.value, "poolId")); + const std::string pool_data = jStr(pool.value("account", json::object()), "data"); + + const FfiResult quoteResult = call(amm_swap_exact_out_quote, json{ + {"tokenInId", token_in_hex}, + {"tokenOutId", token_out_hex}, + {"amountOutRaw", amount_out_decimal}, + {"slippageBps", slippage_bps}, + {"poolData", pool_data}, + }); + if (!quoteResult.ok) + return error(quoteResult.error.empty() ? "backend_error" : quoteResult.error); + + // Success: wrap { requiredInRaw, maxInRaw, priceImpactBps } in the envelope. + LogosMap out = quoteResult.value; + out["status"] = "ok"; + out["error"] = ""; + return out; +} + std::string AmmModuleImpl::swapExactInput(const std::string& def_a_hex, const std::string& def_b_hex, const std::string& user_input_holding_hex, diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index c99796f..aa68113 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -54,6 +54,19 @@ public: const nlohmann::json& amount_in, int64_t slippage_bps); + /// Prices a `SwapExactOutput` for the (token_in_hex, token_out_hex) pair: + /// reads the pool and returns `{ status:"ok", error:"", requiredInRaw, + /// maxInRaw, priceImpactBps }`, oriented and computed server-side via the + /// shared on-chain formula. `amount_out` accepts a JSON integer or a decimal + /// string (JSON floats rejected); `slippage_bps` is basis points. On failure: + /// `{ status:"error", error: }` — `no_pool` (no pool / liquidity), + /// `output_exceeds_liquidity` (amount_out ≥ reserve), `config_missing` + /// (AMM_PROGRAM_BIN unset/unreadable), `bad_amount`, or `backend_error`. + LogosMap swapExactOutQuote(const std::string& token_in_hex, + const std::string& token_out_hex, + const nlohmann::json& amount_out, + int64_t slippage_bps); + /// Submits an on-chain SwapExactInput transaction against the pool for /// (def_a_hex = token in, def_b_hex = token out). amount_in / min_out are /// u128 base-unit amounts; deadline is a u64 unix-ms timestamp. Each accepts