From e302ba557a4357bd060cdb450a63e090ea2cf930 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:27:02 +0200 Subject: [PATCH] feat(modules/amm): add swap_exact_in_quote op and module swapExactInQuote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side SwapExactInput preview. The swap_exact_in_quote FFI op orients the pool reserves to the requested in/out direction, prices via the shared amm_core::swap_exact_in_amounts (so expectedOut matches the chain), and derives the slippage floor: { expectedOutRaw, minReceivedRaw, priceImpactBps }. no_pool is returned as an error; pool metadata (reserves/fee) comes from resolve_pool, so it isn't echoed. Read-only — no quoteHash; the on-chain min_amount_out is the real guard. The module swapExactInQuote(tokenIn, tokenOut, amountIn, slippageBps) method derives the pool via the config-free pool_id op, reads it, and wraps the op in the { status, error, ... } envelope; call() now surfaces the op error code so no_pool propagates. The QML swap view still uses the old path, so nothing breaks. Will make the QML consume it in a follow-up. --- modules/amm/ffi/include/amm_ffi.h | 2 + modules/amm/ffi/src/api/mod.rs | 9 +- modules/amm/ffi/src/api/request.rs | 12 ++ modules/amm/ffi/src/api/swap.rs | 230 +++++++++++++++++++++++++++- modules/amm/ffi/src/ffi.rs | 9 +- modules/amm/ffi/src/lib.rs | 10 +- modules/amm/src/amm_module_impl.cpp | 72 ++++++++- modules/amm/src/amm_module_impl.h | 14 ++ programs/amm/core/src/lib.rs | 64 ++++++++ 9 files changed, 404 insertions(+), 18 deletions(-) diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index e29c1ce..e6ccfe3 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -32,6 +32,8 @@ char *amm_resolve_pool(const char *request_json); char *amm_pool_id(const char *request_json); +char *amm_swap_exact_in_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 e8f67d5..47b15d6 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -22,8 +22,8 @@ use std::{error::Error, fmt}; pub use request::{ ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PoolIdRequest, - PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapPairRequest, - SwapPlanRequest, TokenIdsRequest, + PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInQuoteRequest, + SwapPairRequest, SwapPlanRequest, TokenIdsRequest, }; use serde_json::Value; @@ -108,6 +108,11 @@ pub fn pool_id(request: PoolIdRequest) -> AmmResult { swap::pool_id(request).map_err(Into::into) } +/// Prices a `SwapExactInput`: expected output, slippage floor, and price impact. +pub fn swap_exact_in_quote(request: SwapExactInQuoteRequest) -> AmmResult { + swap::swap_exact_in_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 ae19112..cfb98ce 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -67,6 +67,18 @@ pub struct ResolvePoolRequest { pub pool: AccountRead, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SwapExactInQuoteRequest { + pub token_in_id: String, + pub token_out_id: String, + pub amount_in_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 c4d5646..c4f764b 100644 --- a/modules/amm/ffi/src/api/swap.rs +++ b/modules/amm/ffi/src/api/swap.rs @@ -3,14 +3,18 @@ //! new-position ops: pure functions returning JSON `Value`, PDAs reused from //! `pair::derive_pair` so the swap path never re-derives seeds. -use amm_core::{compute_pool_pda, PoolDefinition}; +use amm_core::{ + compute_pool_pda, mul_div_floor, price_impact_bps, swap_exact_in_amounts, PoolDefinition, + FEE_BPS_DENOMINATOR, +}; use nssa_core::account::AccountId; use risc0_binfmt::ProgramBinary; use serde_json::{json, Value}; use super::{ pair::{derive_pair, is_canonical_pair}, - PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapPairRequest, SwapPlanRequest, + PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapExactInQuoteRequest, SwapPairRequest, + SwapPlanRequest, }; use crate::account::{ account_id_from_hex, account_id_hex, decode_account, parse_program_id, program_id_bytes, @@ -82,8 +86,7 @@ pub(super) fn resolve_pool(request: ResolvePoolRequest) -> Result if pool.liquidity_pool_supply == 0 { return Ok(json!({ "exists": false })); } - let fee_bps = - u32::try_from(pool.fees).map_err(|_| String::from("pool fee tier exceeds u32"))?; + let fee_bps = u32::try_from(pool.fees).map_err(|_| String::from("invalid_fee_tier"))?; Ok(json!({ "exists": true, "defAHex": account_id_hex(pool.definition_token_a_id), @@ -103,12 +106,81 @@ pub(super) fn pool_id(request: PoolIdRequest) -> 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("pool_id requires two distinct tokens")); + return Err(String::from("same_token_pair")); } let (token_a, token_b) = canonical_pair(token_in, token_out); Ok(json!({ "poolId": account_id_hex(compute_pool_pda(amm_program, token_a, token_b)) })) } +/// Prices a `SwapExactInput`: orients the pool's reserves to the requested in/out +/// direction and applies the exact on-chain output via the shared +/// `amm_core::swap_exact_in_amounts` (so `expectedOut` equals what the guest +/// produces), then derives the slippage floor `minReceived`. Read-only preview — +/// no `quoteHash`; `swap_exact_input` re-prices fresh at submit and the on-chain +/// `min_amount_out` is the real guard. Errors are stable short codes callers can +/// branch on: `no_pool` (pool absent / undecodable / no liquidity), +/// `same_token_pair`, `invalid_slippage` (slippage ≥ 100%), `pair_mismatch` (the +/// decoded pool isn't for this pair), `amount_too_small` (the input fee-rounds to +/// zero effective input or zero output — the guest would reject it on submit). +/// Pool metadata (reserves, fee) comes from `resolve_pool`, so it isn't echoed here. +pub(super) fn swap_exact_in_quote(request: SwapExactInQuoteRequest) -> 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_in = parse_u128(&request.amount_in_raw, "amountInRaw")?; + 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 to the requested direction: the pool stores canonical + // (token_a, token_b); the sold token selects the deposit-side reserve. + 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")); + }; + if reserve_in == 0 || reserve_out == 0 { + return Err(String::from("no_pool")); + } + + // Exact on-chain pricing (shared with amm_program::swap), then the slippage floor. + let (effective_in, expected_out) = + swap_exact_in_amounts(amount_in, reserve_in, reserve_out, pool.fees); + // Mirror the guest's swap_logic guards: an input that fee-rounds to zero + // effective input (e.g. "0", or "1" at 30 bps) or yields zero output would be + // rejected on submit before any transfer, so it must not preview as a valid + // quote either. + if effective_in == 0 || expected_out == 0 { + return Err(String::from("amount_too_small")); + } + let slippage_complement = FEE_BPS_DENOMINATOR - u128::from(request.slippage_bps); + let min_received = mul_div_floor(expected_out, slippage_complement, FEE_BPS_DENOMINATOR); + + // Price impact (display): how far the realized output falls below the naive + // spot valuation, in bps (fee + curve movement combined). Computed wide so an + // out-of-range naive valuation can't overflow/panic. + let price_impact_bps = price_impact_bps(amount_in, expected_out, reserve_in, reserve_out); + + Ok(json!({ + "expectedOutRaw": expected_out.to_string(), + "minReceivedRaw": min_received.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 @@ -281,6 +353,154 @@ mod tests { assert_eq!(plan, expected); } + fn pool_data_hex(pool: &PoolDefinition) -> String { + hex::encode(borsh::to_vec(pool).unwrap()) + } + + #[test] + fn swap_quote_prices_via_shared_formula_and_orients() { + 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() + }; + + // Sell A → receive B: reserveIn = reserve_a, reserveOut = reserve_b. + let ab = swap_exact_in_quote(SwapExactInQuoteRequest { + token_in_id: account_id_hex(def_a), + token_out_id: account_id_hex(def_b), + amount_in_raw: "10000".into(), + slippage_bps: 50, + pool_data: pool_data_hex(&pool), + }) + .unwrap(); + // expectedOut comes from the shared on-chain formula (single source of truth). + let (_, expected_out) = swap_exact_in_amounts(10_000, 1_000_000, 2_000_000, 30); + assert_eq!(ab["expectedOutRaw"], expected_out.to_string()); + assert_eq!( + ab["minReceivedRaw"], + (expected_out * (FEE_BPS_DENOMINATOR - 50) / FEE_BPS_DENOMINATOR).to_string() + ); + assert!(ab["priceImpactBps"].is_number()); + // Only the priced results are echoed — no pool metadata. + assert!(ab.get("reserveInRaw").is_none()); + assert!(ab.get("feeBps").is_none()); + assert!(ab.get("poolStatus").is_none()); + + // Reverse direction orients reserves the other way. + let ba = swap_exact_in_quote(SwapExactInQuoteRequest { + token_in_id: account_id_hex(def_b), + token_out_id: account_id_hex(def_a), + amount_in_raw: "10000".into(), + slippage_bps: 50, + pool_data: pool_data_hex(&pool), + }) + .unwrap(); + let (_, expected_out_ba) = swap_exact_in_amounts(10_000, 2_000_000, 1_000_000, 30); + assert_eq!(ba["expectedOutRaw"], expected_out_ba.to_string()); + } + + #[test] + fn swap_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 { + token_in_id: account_id_hex(def_a), + token_out_id: account_id_hex(def_b), + amount_in_raw: "10000".into(), + slippage_bps: 50, + pool_data, + }; + // Empty / undecodable pool data. + assert_eq!( + swap_exact_in_quote(req(String::new())), + Err(String::from("no_pool")) + ); + // Zero-supply pool. + let empty_pool = PoolDefinition { + definition_token_a_id: def_a, + definition_token_b_id: def_b, + liquidity_pool_supply: 0, + ..Default::default() + }; + assert_eq!( + swap_exact_in_quote(req(pool_data_hex(&empty_pool))), + Err(String::from("no_pool")) + ); + } + + #[test] + fn swap_quote_rejects_zero_and_fee_rounded_inputs() { + 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: &str| SwapExactInQuoteRequest { + token_in_id: account_id_hex(def_a), + token_out_id: account_id_hex(def_b), + amount_in_raw: amount.into(), + slippage_bps: 50, + pool_data: pool_data_hex(&pool), + }; + // amount_in = 0 → zero effective input; the guest's swap_logic would reject + // it before any transfer, so the preview must not report an executable quote. + assert_eq!( + swap_exact_in_quote(req("0")), + Err(String::from("amount_too_small")) + ); + // amount_in = 1 fee-rounds to zero effective input at 30 bps. + assert_eq!( + swap_exact_in_quote(req("1")), + Err(String::from("amount_too_small")) + ); + // A normal amount above the fee-rounding floor still quotes. + assert!(swap_exact_in_quote(req("10000")).unwrap()["expectedOutRaw"].is_string()); + } + + #[test] + fn swap_quote_handles_out_of_range_spot_valuation() { + // reserve_in = 1, reserve_out = u128::MAX: the naive spot valuation of the + // input (reserve_out * amount_in / reserve_in) overflows u128. The quote + // must still price it (display price impact stays bounded, no panic). + 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, + reserve_a: 1, + reserve_b: u128::MAX, + fees: 30, + ..Default::default() + }; + let quote = swap_exact_in_quote(SwapExactInQuoteRequest { + token_in_id: account_id_hex(def_a), + token_out_id: account_id_hex(def_b), + amount_in_raw: "2".into(), + slippage_bps: 50, + pool_data: pool_data_hex(&pool), + }) + .unwrap(); + assert!(quote["expectedOutRaw"].is_string()); + assert!( + quote["priceImpactBps"].as_u64().unwrap() + <= u64::try_from(FEE_BPS_DENOMINATOR).unwrap() + ); + } + #[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 0667635..16ebe33 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -7,8 +7,8 @@ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, - PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapPairRequest, - SwapPlanRequest, TokenIdsRequest, + PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInQuoteRequest, + SwapPairRequest, SwapPlanRequest, TokenIdsRequest, }; #[derive(Serialize)] @@ -122,6 +122,11 @@ pub extern "C" fn amm_pool_id(request_json: *const c_char) -> *mut c_char { call::(request_json, api::pool_id) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_swap_exact_in_quote(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::swap_exact_in_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 8e2d32f..56a9a2f 100644 --- a/modules/amm/ffi/src/lib.rs +++ b/modules/amm/ffi/src/lib.rs @@ -6,9 +6,9 @@ mod ffi; pub mod api; pub use api::{ - config_id, context, pair_ids, plan, pool_id, program_id, quote, resolve_pool, swap_pair, - swap_plan, token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult, ConfigIdRequest, - ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, - ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapPairRequest, SwapPlanRequest, - TokenIdsRequest, WalletAccount, + 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, }; diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index 825620f..dd0ea4c 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -139,6 +139,11 @@ bool jsonAmountToDecimal(const json& j, std::string& out) { s = s.substr(1, s.size() - 2); trim(s); } + // A valid base-unit amount is a non-empty run of decimal digits. Reject + // anything else (empty, signs, decimal points, exponents, letters) here + // rather than passing it downstream to surface as an opaque backend error. + if (s.empty() || s.find_first_not_of("0123456789") != std::string::npos) + return false; out = s; return true; } @@ -193,10 +198,13 @@ std::vector jsonWordsToLeBytes(const json& arr) { return out; } -// Result of an amm_ffi JSON op: the `{ ok, value }` envelope decoded. +// Result of an amm_ffi JSON op: the `{ ok, value, error }` envelope decoded. +// `error` carries the op's failure code (e.g. "no_pool") when `ok` is false, so +// callers can surface it in their own response envelope. struct FfiResult { bool ok = false; json value; + std::string error; }; // Serialize `request`, hand it to an amm_ffi op, and decode its @@ -218,15 +226,16 @@ FfiResult call(char* (*op)(const char*), const json& request) { return {}; } if (!doc.value("ok", false)) { - AMM_TRACE("amm_ffi op failure: " << doc.value("error", std::string())); - return {}; + std::string error = doc.value("error", std::string()); + AMM_TRACE("amm_ffi op failure: " << error); + return {false, json(), std::move(error)}; } const auto it = doc.find("value"); if (it == doc.end() || !it->is_object()) { AMM_TRACE("amm_ffi op value is not an object"); return {}; } - return {true, *it}; + return {true, *it, {}}; } // new-position response envelope builders. @@ -471,6 +480,61 @@ LogosMap AmmModuleImpl::resolvePool(const std::string& def_a_hex, return resolved; // { exists:true, reserveA, reserveB, feeBps } } +LogosMap AmmModuleImpl::swapExactInQuote(const std::string& token_in_hex, + const std::string& token_out_hex, + const nlohmann::json& amount_in, + int64_t slippage_bps) { + auto error = [](const std::string& err) { + return LogosMap{{"status", "error"}, {"error", err}}; + }; + + // amountIn arrives as a JSON number (CLI) or decimal string (UI); coerce to a + // canonical decimal string (rejects floats — see jsonAmountToDecimal). + std::string amount_in_decimal; + if (!jsonAmountToDecimal(amount_in, amount_in_decimal)) + return error("bad_amount"); + + // slippageBps is a fraction of 100% in basis points. The FFI request field is + // a u32, so a negative value would fail deserialization with an opaque serde + // message; gate the full range here for a stable code (100% = 10000 bps, the + // FFI's FEE_BPS_DENOMINATOR, which also rejects the upper bound as a backstop). + if (slippage_bps < 0 || slippage_bps >= 10000) + return error("invalid_slippage"); + + 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_in_quote, json{ + {"tokenInId", token_in_hex}, + {"tokenOutId", token_out_hex}, + {"amountInRaw", amount_in_decimal}, + {"slippageBps", slippage_bps}, + {"poolData", pool_data}, + }); + if (!quoteResult.ok) + return error(quoteResult.error.empty() ? "backend_error" : quoteResult.error); + + // Success: wrap the priced payload { expectedOutRaw, minReceivedRaw, + // priceImpactBps } in the standard 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 a7a31e8..c99796f 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -40,6 +40,20 @@ public: /// `no_pool` for the ordinary "no pool / no liquidity yet" state. LogosMap resolvePool(const std::string& def_a_hex, const std::string& def_b_hex); + /// Prices a `SwapExactInput` for the (token_in_hex, token_out_hex) pair: + /// reads the pool and returns `{ status:"ok", error:"", expectedOutRaw, + /// minReceivedRaw, priceImpactBps }`, oriented and computed server-side via + /// the shared on-chain formula. `amount_in` 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), `config_missing` (AMM_PROGRAM_BIN unset/unreadable), + /// `bad_amount`, or `backend_error`. Pool metadata (reserves, fee) comes from + /// `resolvePool`, so it isn't echoed here. + LogosMap swapExactInQuote(const std::string& token_in_hex, + const std::string& token_out_hex, + const nlohmann::json& amount_in, + 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 diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 3351493..ac09a86 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -334,6 +334,50 @@ pub fn mul_div_ceil(a: u128, b: u128, c: u128) -> u128 { u128::try_from(result).expect("mul_div_ceil result exceeds u128") } +/// Adverse price impact of a swap in basis points: how far `amount_out` falls +/// below the naive spot-price valuation of `amount_in` +/// (`reserve_out * amount_in / reserve_in`). Display-only — it never panics. +/// +/// The naive valuation can exceed u128 for extreme reserve ratios (e.g. +/// `reserve_out` near `u128::MAX` with a tiny `reserve_in`), which is why it is +/// not materialized as u128: this multiplies the *bounded* +/// `FEE_BPS_DENOMINATOR * amount_out` (≤ ~2^142) first and divides by the wide +/// naive value, so every intermediate stays inside U256 and the result is +/// clamped to `[0, FEE_BPS_DENOMINATOR]`. Returns `0` when the naive valuation +/// rounds to zero (or `reserve_in` is zero). +#[must_use] +pub fn price_impact_bps( + amount_in: u128, + amount_out: u128, + reserve_in: u128, + reserve_out: u128, +) -> u32 { + use alloy_primitives::U256; + if reserve_in == 0 { + return 0; + } + let spot = U256::from(reserve_out) + .checked_mul(U256::from(amount_in)) + .expect("u128 * u128 always fits in U256") + .checked_div(U256::from(reserve_in)) + .expect("reserve_in is non-zero after the guard above"); + if spot.is_zero() { + return 0; + } + // Fraction of the spot value the trader keeps, in bps (≤ FEE_BPS_DENOMINATOR + // since amount_out ≤ spot). The numerator is bounded, so no overflow even when + // `spot` is enormous. + let kept = U256::from(FEE_BPS_DENOMINATOR) + .checked_mul(U256::from(amount_out)) + .expect("FEE_BPS_DENOMINATOR * u128 always fits in U256") + .checked_div(spot) + .expect("spot is non-zero after the is_zero check above"); + let kept = u128::try_from(kept) + .unwrap_or(FEE_BPS_DENOMINATOR) + .min(FEE_BPS_DENOMINATOR); + u32::try_from(FEE_BPS_DENOMINATOR.saturating_sub(kept)).unwrap_or(u32::MAX) +} + /// The constant-product output for a `SwapExactInput`, matching the AMM's on-chain /// pricing exactly — used by both `amm_program::swap` and the off-chain swap quote, /// so the preview and the executed trade agree. Fee-adjusts the input, then applies @@ -679,6 +723,26 @@ mod tests { assert_eq!(swap_exact_in_amounts(1, 0, 0, 30), (0, 0)); } + #[test] + fn price_impact_bps_is_bounded_and_never_panics() { + let max_bps = u32::try_from(FEE_BPS_DENOMINATOR).unwrap(); + + // Normal pool: a small trade has a bounded impact. + let (_, out) = swap_exact_in_amounts(10_000, 1_000_000, 2_000_000, 30); + assert!(price_impact_bps(10_000, out, 1_000_000, 2_000_000) <= max_bps); + + // Extreme reserve ratio: the naive spot valuation + // (reserve_out * amount_in / reserve_in) exceeds u128 — a u128 mul_div here + // would panic. This must stay bounded and not panic. + let reserve_out = u128::MAX; + let (_, out) = swap_exact_in_amounts(2, 1, reserve_out, 30); + assert!(price_impact_bps(2, out, 1, reserve_out) <= max_bps); + + // Zero naive value / zero reserve → zero impact, no division by zero. + assert_eq!(price_impact_bps(1, 0, 0, 0), 0); + assert_eq!(price_impact_bps(1, 0, 1_000_000, 0), 0); + } + #[test] fn mul_div_ceil_small_cases() { assert_eq!(mul_div_ceil(6, 7, 3), 14);