feat(modules/amm): add swap_exact_in_quote op and module swapExactInQuote

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.
This commit is contained in:
r4bbit
2026-08-06 10:40:52 +02:00
parent c3dc9dd94f
commit e302ba557a
9 changed files with 404 additions and 18 deletions
+2
View File
@@ -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);
+7 -2
View File
@@ -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)
+12
View File
@@ -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 {
+225 -5
View File
@@ -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<Value, String>
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<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("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<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_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::<PoolDefinition>(&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);
+7 -2
View File
@@ -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::<PoolIdRequest>(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::<SwapExactInQuoteRequest>(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::<SwapPlanRequest>(request_json, api::swap_plan)
+5 -5
View File
@@ -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,
};
+68 -4
View File
@@ -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<uint8_t> 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,
+14
View File
@@ -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:<code> }` — `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