feat(modules/amm): add swap_exact_out_quote op and module swapExactOutQuote

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.
This commit is contained in:
r4bbit
2026-08-06 12:51:58 +02:00
parent 8fba03d106
commit 513fea5fcb
8 changed files with 252 additions and 12 deletions
+2
View File
@@ -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);
+6 -1
View File
@@ -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)
+12
View File
@@ -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 {
+163 -6
View File
@@ -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<Va
}))
}
/// Prices a `SwapExactOutput`: for a desired output amount, computes the input
/// required via the shared `amm_core::swap_exact_out_amounts` (matching what the
/// guest charges) and the slippage ceiling `maxIn`. Read-only preview — no
/// `quoteHash`; the on-chain `max_amount_in` is the real guard. Errors are stable
/// short codes callers can branch on: `no_pool` (pool absent / undecodable / no
/// liquidity, incl. a zero reserve), `same_token_pair`, `invalid_slippage`
/// (slippage ≥ 100%), `pair_mismatch` (the decoded pool isn't for this pair),
/// `amount_too_small` (zero requested output — the guest would reject it),
/// `output_exceeds_liquidity` (asking for at least the whole reserve). Pool
/// metadata (reserves/fee) comes from `resolve_pool`.
pub(super) fn swap_exact_out_quote(request: SwapExactOutQuoteRequest) -> Result<Value, String> {
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::<PoolDefinition>(&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);
+6 -1
View File
@@ -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::<SwapExactInQuoteRequest>(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::<SwapExactOutQuoteRequest>(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::<SwapPlanRequest>(request_json, api::swap_plan)
+5 -4
View File
@@ -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,
};
+45
View File
@@ -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,
+13
View File
@@ -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:<code> }` — `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