mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user