feat(modules/amm): add_liquidity_quote takes slippage, returns minimumLpRaw

The add-liquidity quote now takes slippageBps and returns minimumLpRaw =
floor(delta_lp * (10000 - slippage) / 10000) — the LP floor the submit passes as
min_amount_liquidity, mirroring the swap quotes' minReceivedRaw. Computing it in
Rust keeps the u128 slippage math out of the UI. Adds invalid_slippage (>= 100%)
and minimum_lp_zero (slippage leaves no floor) errors; the module's
addLiquidityQuote forwards slippageBps.
This commit is contained in:
r4bbit
2026-08-11 17:21:46 +02:00
parent 67973b1c0d
commit 77cb982619
3 changed files with 64 additions and 10 deletions
+45 -9
View File
@@ -12,7 +12,8 @@
//! guest's `assert pool uninitialized`.
use amm_core::{
isqrt_product, mul_div_floor, spot_price_q64_64, PoolDefinition, MINIMUM_LIQUIDITY,
isqrt_product, mul_div_floor, spot_price_q64_64, PoolDefinition, FEE_BPS_DENOMINATOR,
MINIMUM_LIQUIDITY,
};
use nssa_core::account::AccountId;
use serde_json::{json, Value};
@@ -202,13 +203,14 @@ pub(super) fn create_pool_plan(request: CreatePoolPlanRequest) -> Result<Value,
/// caller's max amounts to the pool's canonical `(a, b)` order, then run the guest's exact
/// proportional-deposit math (`amm_program::add::add_liquidity`): the ideal→actual clamp
/// and `delta_lp = min(supply·actual_a/reserve_a, supply·actual_b/reserve_b)`. Returns the
/// same shape as the create quote minus the create-only locked LP: the actual ratio-matched
/// deposits (display order), the LP minted, and the pool's spot price (`priceRaw`, token B
/// per token A in display order). The slippage floor is applied at submit, not here — the
/// quote is a pure preview like create. Errors: `same_token_pair`, `no_pool`,
/// `pair_mismatch` (the pool isn't for this pair), bad amounts (`amount_required`,
/// `invalid_raw_amount`, `amount_must_be_positive`), `amount_too_low` (the deposit rounds
/// to zero LP — nothing to mint).
/// actual ratio-matched deposits (display order), the LP minted (`expectedLpRaw`), the
/// slippage floor on that LP (`minimumLpRaw = floor(delta_lp · (1 slippage))`, the
/// submit's `min_amount_liquidity` — like the swap quotes' `minReceivedRaw`), and the pool's
/// spot price (`priceRaw`, token B per token A in display order). Errors: `same_token_pair`,
/// `no_pool`, `pair_mismatch` (the pool isn't for this pair), `invalid_slippage` (≥ 100%),
/// bad amounts (`amount_required`, `invalid_raw_amount`, `amount_must_be_positive`),
/// `amount_too_low` (the deposit rounds to zero LP), `minimum_lp_zero` (slippage leaves no
/// LP floor — the guest requires a nonzero minimum).
pub(super) fn add_liquidity_quote(request: AddLiquidityQuoteRequest) -> Result<Value, String> {
let token_a = account_id_from_hex(&request.token_a_id, "token A id")?;
let token_b = account_id_from_hex(&request.token_b_id, "token B id")?;
@@ -217,6 +219,9 @@ pub(super) fn add_liquidity_quote(request: AddLiquidityQuoteRequest) -> Result<V
}
let max_a = positive_amount(Some(&request.max_amount_a_raw))?;
let max_b = positive_amount(Some(&request.max_amount_b_raw))?;
if u128::from(request.slippage_bps) >= FEE_BPS_DENOMINATOR {
return Err(String::from("invalid_slippage"));
}
// Decode the pool; absent / undecodable / empty ⇒ nothing to add to.
let pool = hex::decode(&request.pool_data)
@@ -258,6 +263,14 @@ pub(super) fn add_liquidity_quote(request: AddLiquidityQuoteRequest) -> Result<V
return Err(String::from("amount_too_low"));
}
// Slippage floor on the LP minted (orientation-independent — LP is symmetric). The guest
// requires a nonzero `min_amount_liquidity`, so reject a slippage that rounds it to zero.
let slippage_complement = FEE_BPS_DENOMINATOR - u128::from(request.slippage_bps);
let minimum_lp = mul_div_floor(delta_lp, slippage_complement, FEE_BPS_DENOMINATOR);
if minimum_lp == 0 {
return Err(String::from("minimum_lp_zero"));
}
// Back to display order for the response; the price uses the display-oriented reserves.
let (display_a, display_b) = if reversed {
(actual_b, actual_a)
@@ -275,6 +288,7 @@ pub(super) fn add_liquidity_quote(request: AddLiquidityQuoteRequest) -> Result<V
"amountARaw": display_a.to_string(),
"amountBRaw": display_b.to_string(),
"expectedLpRaw": delta_lp.to_string(),
"minimumLpRaw": minimum_lp.to_string(),
"priceRaw": price.to_string(),
}))
}
@@ -587,17 +601,19 @@ mod tests {
token_b_id: account_id_hex(def_b),
max_amount_a_raw: String::from("10000"),
max_amount_b_raw: String::from("100000"),
slippage_bps: 50,
pool_data: pool_hex(&pool),
})
.unwrap();
assert_eq!(ab["amountARaw"], "10000");
assert_eq!(ab["amountBRaw"], "20000");
assert_eq!(ab["expectedLpRaw"], "10000");
// minimumLpRaw = floor(10000 * (10000 - 50) / 10000) = 9950 (slippage floor on LP).
assert_eq!(ab["minimumLpRaw"], "9950");
assert_eq!(
ab["priceRaw"],
spot_price_q64_64(1_000_000, 2_000_000).to_string()
);
// Shape parity with create, minus the create-only locked LP and with priceRaw.
assert!(ab.get("lockedLpRaw").is_none());
assert!(ab.get("initialPriceRaw").is_none());
@@ -607,6 +623,7 @@ mod tests {
token_b_id: account_id_hex(def_a),
max_amount_a_raw: String::from("100000"),
max_amount_b_raw: String::from("10000"),
slippage_bps: 50,
pool_data: pool_hex(&pool),
})
.unwrap();
@@ -639,6 +656,7 @@ mod tests {
token_b_id: account_id_hex(token_b),
max_amount_a_raw: max_a.into(),
max_amount_b_raw: max_b.into(),
slippage_bps: 50,
pool_data: data,
}
};
@@ -675,6 +693,24 @@ mod tests {
add_liquidity_quote(req(def_a, def_b, "1", "1", pool_hex(&pool))),
Err(String::from("amount_too_low"))
);
// A deposit that mints only 1 LP: 50 bps slippage floors the minimum to 0, which the
// guest's nonzero `min_amount_liquidity` rejects.
assert_eq!(
add_liquidity_quote(req(def_a, def_b, "1", "2", pool_hex(&pool))),
Err(String::from("minimum_lp_zero"))
);
// Slippage at/above 100%.
assert_eq!(
add_liquidity_quote(AddLiquidityQuoteRequest {
token_a_id: account_id_hex(def_a),
token_b_id: account_id_hex(def_b),
max_amount_a_raw: String::from("10000"),
max_amount_b_raw: String::from("10000"),
slippage_bps: 10_000,
pool_data: pool_hex(&pool),
}),
Err(String::from("invalid_slippage"))
);
}
#[test]
+4
View File
@@ -178,6 +178,10 @@ pub struct AddLiquidityQuoteRequest {
pub token_b_id: String,
pub max_amount_a_raw: String,
pub max_amount_b_raw: String,
/// Slippage tolerance in basis points — the quote returns `minimumLpRaw`, the LP floor
/// the submit accepts (like the swap quotes take `slippageBps` → `minReceivedRaw`).
#[serde(default)]
pub slippage_bps: u32,
pub pool_data: String,
}
+15 -1
View File
@@ -896,17 +896,31 @@ LogosMap AmmModuleImpl::addLiquidityQuote(const LogosMap& request) {
const json pool = readPublicAccount(jStr(poolId.value, "poolId"));
const std::string pool_data = jStr(pool.value("account", json::object()), "data");
// slippageBps is a fraction of 100% in basis points; the pricing op uses it to derive
// minimumLpRaw (the LP floor the submit accepts). Require an integer JSON number and reject
// everything else with a stable invalid_slippage: is_number() would also accept a float
// (and get<int64_t>() on a number_float THROWS, terminating the module), while a string /
// bool would otherwise fall through to a silent 0. A missing field defaults to 0 (no
// slippage). A negative or >= 100% value is likewise invalid_slippage.
const json slippage_val = request.value("slippageBps", json(0));
if (!slippage_val.is_number_integer())
return error("invalid_slippage");
const int64_t slippage_bps = slippage_val.get<int64_t>();
if (slippage_bps < 0 || slippage_bps >= 10000)
return error("invalid_slippage");
const FfiResult quoteResult = call(amm_add_liquidity_quote, json{
{"tokenAId", token_a},
{"tokenBId", token_b},
{"maxAmountARaw", max_a_decimal},
{"maxAmountBRaw", max_b_decimal},
{"slippageBps", slippage_bps},
{"poolData", pool_data},
});
if (!quoteResult.ok)
return error(quoteResult.error.empty() ? "backend_error" : quoteResult.error);
// Success: wrap { amountARaw, amountBRaw, expectedLpRaw, priceRaw } in the envelope.
// Success: wrap { amountARaw, amountBRaw, expectedLpRaw, minimumLpRaw, priceRaw }.
LogosMap out = quoteResult.value;
out["status"] = "ok";
out["error"] = "";