Files
lez-programs/modules/amm/ffi/src/api/swap.rs
T
r4bbit 92f55652a8 refactor(amm): enrich resolvePool into resolvePoolAccount
Return the pool's full derived state from one read instead of just existence +
reserves, so callers get the derived accounts (for future account views / oracle
setup) without re-deriving.

FFI resolve_pool: drop the `exists` boolean — the presence of data is the signal.
An existing pool returns { status:"ok", ..., poolId, defAHex, defBHex, vaultAId,
vaultBId, lpDefinitionId, reserveA, reserveB, liquiditySupply, feeBps }; a missing /
uninitialized pool is the { status:"error", error:"no_pool", poolId } error (still
carrying the derived poolId for address derivation).

Module: resolvePool -> resolvePoolAccount — { status:"error", error } envelope for
hard failures, and orient reserves + defs + vaults to the caller's requested order.

Backend + QML: rename the slot; SwapCard and NewPositionFlow switch the existence
check from pool.exists to pool.status === "ok" (reserve field names unchanged, so
no other consumer edits). no_pool still routes to create-pool; hard errors still
surface.
2026-08-13 16:55:45 +02:00

869 lines
36 KiB
Rust

//! Swap operations — pool discovery/decode, swap-transaction planning, and
//! program-id derivation. Same transport-independent pattern as the
//! 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, 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;
use serde_json::{json, Value};
use super::{
pair::{derive_pair, is_canonical_pair},
PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapExactInPlanRequest,
SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest,
};
use crate::account::{
account_id_from_hex, account_id_hex, decode_account, parse_program_id, program_id_bytes,
};
/// Orders `(token_in, token_out)` into the pool's canonical `(token_a, token_b)`
/// so derived vault PDAs line up with the pool's stored `vault_a`/`vault_b`.
fn canonical_pair(token_in: AccountId, token_out: AccountId) -> (AccountId, AccountId) {
if is_canonical_pair(token_in, token_out) {
(token_in, token_out)
} else {
(token_out, token_in)
}
}
fn parse_u128(value: &str, label: &str) -> Result<u128, String> {
value
.parse::<u128>()
.map_err(|error| format!("invalid {label}: {error}"))
}
fn parse_u64(value: &str, label: &str) -> Result<u64, String> {
value
.parse::<u64>()
.map_err(|error| format!("invalid {label}: {error}"))
}
/// Derives the canonical account ids for a swap pair — reuses `derive_pair`
/// after ordering `(token_in, token_out)` into `(token_a, token_b)`, so a
/// caller can read the pool account before decoding it. Like `pair_ids`, but
/// accepts the tokens in either order.
pub(super) fn swap_pair(request: SwapPairRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
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 Ok(json!({ "status": "error", "code": "same_token_pair" }));
}
let (token_a, token_b) = canonical_pair(token_in, token_out);
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Ok(json!({ "status": "error", "code": "config_unavailable" }));
};
Ok(json!({
"status": "ok",
"configId": account_id_hex(pair.config),
"poolId": account_id_hex(pair.pool),
"vaultAId": account_id_hex(pair.vault_a),
"vaultBId": account_id_hex(pair.vault_b),
"currentTickId": account_id_hex(pair.current_tick),
"clockId": account_id_hex(pair.clock),
}))
}
/// Decodes a pool account: whether it holds liquidity, its canonical token ids
/// (`defAHex`/`defBHex`), its reserves (same canonical `a`/`b` order — the
/// caller matches a reserve to its own direction by comparing its token id
/// against `defAHex`), and fee tier. On success returns a `{ status: "ok",
/// error: "", poolId, defAHex, defBHex, vaultAId, vaultBId, lpDefinitionId,
/// reserveA, reserveB, liquiditySupply, feeBps }` envelope; an absent / empty /
/// uninitialized pool is the `{ status: "error", error: "no_pool", poolId }`
/// envelope (the derived `poolId` is still carried, for address derivation).
pub(super) fn resolve_pool(request: ResolvePoolRequest) -> Result<Value, String> {
// The pool's presence IS the signal: existing pools return their decoded state; a missing /
// uninitialized pool is the `no_pool` error (still carrying the derived `poolId` for address
// derivation). The read's id is hex (the module reads by hex); undecodable ⇒ "".
let pool_id = account_id_from_hex(&request.pool.id, "pool id")
.map(|id| id.to_string())
.unwrap_or_default();
let missing = || json!({ "status": "error", "error": "no_pool", "poolId": pool_id });
if request.pool.status != "ok" {
return Ok(missing());
}
let Ok((_, pool_account)) = decode_account(&request.pool) else {
return Ok(missing());
};
let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else {
return Ok(missing());
};
if pool.liquidity_pool_supply == 0 {
return Ok(missing());
}
let fee_bps = u32::try_from(pool.fees).map_err(|_| String::from("invalid_fee_tier"))?;
Ok(json!({
"status": "ok",
"error": "",
"poolId": pool_id,
"defAHex": account_id_hex(pool.definition_token_a_id),
"defBHex": account_id_hex(pool.definition_token_b_id),
"vaultAId": pool.vault_a_id.to_string(),
"vaultBId": pool.vault_b_id.to_string(),
"lpDefinitionId": pool.liquidity_pool_id.to_string(),
"reserveA": pool.reserve_a.to_string(),
"reserveB": pool.reserve_b.to_string(),
"liquiditySupply": pool.liquidity_pool_supply.to_string(),
"feeBps": fee_bps,
}))
}
/// Derives the pool PDA for a swap pair (tokens in either order). Config-free —
/// the pool address depends only on the AMM program id and the two token ids, so
/// a caller that just needs to read the pool doesn't have to load the config
/// first (unlike `swap_pair`, which also derives the config-dependent tick PDA).
pub(super) fn pool_id(request: PoolIdRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
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 (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,
}))
}
/// 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 from the pool's stored ids, only the user's input holding
/// signs) and the instruction words (`risc0_zkvm::serde` — the same encoding
/// the guest decodes). On success returns exactly the tx-submission fields
/// (`programId`, `accountIds`, `signingRequirements`, `instruction`; the
/// deadline is already baked into the instruction bytes). Every recoverable
/// domain failure fails CLOSED as `Err` (surfaced through the FFI envelope as
/// `{ ok: false, error }`): `same_token_pair`, `config_unavailable`, and a
/// missing/undecodable pool as `Err("no_pool")` — so the caller stops on
/// `planResult.ok` rather than submit a tx with empty account/instruction
/// vectors.
pub(super) fn swap_exact_in_plan(request: SwapExactInPlanRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
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 (token_a, token_b) = canonical_pair(token_in, token_out);
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Err(String::from("config_unavailable"));
};
// Take the vaults from the pool's STORED ids, in its `def_a`/`def_b` (creation)
// order — the guest asserts the provided vaults against `pool.vault_a_id` /
// `vault_b_id`, which needn't match `derive_pair`'s canonical order for a
// non-canonically created pool. (`pair.pool`/`current_tick`/`clock` are
// order-independent, so they still come from `derive_pair`.)
let Some(pool) = hex::decode(&request.pool_data)
.ok()
.and_then(|bytes| borsh::from_slice::<PoolDefinition>(&bytes).ok())
else {
return Err(String::from("no_pool"));
};
let user_input_holding =
account_id_from_hex(&request.user_input_holding_id, "user input holding id")?;
let user_output_holding =
account_id_from_hex(&request.user_output_holding_id, "user output holding id")?;
let swap_amount_in = parse_u128(&request.amount_in, "amountIn")?;
let min_amount_out = parse_u128(&request.min_out, "minOut")?;
let deadline = parse_u64(&request.deadline_ms, "deadlineMs")?;
let instruction = risc0_zkvm::serde::to_vec(&amm_core::Instruction::SwapExactInput {
swap_amount_in,
min_amount_out,
deadline,
})
.map_err(|error| format!("instruction serialization failed: {error}"))?;
// Fixed IDL account order for SwapExactInput; only user_input_holding signs.
let account_ids = [
pair.config,
pair.pool,
pool.vault_a_id,
pool.vault_b_id,
user_input_holding,
user_output_holding,
pair.current_tick,
pair.clock,
];
let signing_requirements = [false, false, false, false, true, false, false, false];
Ok(json!({
"programId": request.amm_program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
}))
}
/// Builds the `SwapExactOutput` submission for a pair: the same fixed 8-account
/// IDL order and error contract as `swap_exact_in_plan` — every recoverable
/// failure fails closed as `Err` (`same_token_pair`, `config_unavailable`, and
/// `Err("no_pool")` for a missing/undecodable pool). Only the encoded
/// instruction differs, carrying `exact_amount_out` / `max_amount_in` in place
/// of the exact-in bounds.
pub(super) fn swap_exact_out_plan(request: SwapExactOutPlanRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
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 (token_a, token_b) = canonical_pair(token_in, token_out);
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Err(String::from("config_unavailable"));
};
// Take the vaults from the pool's STORED ids, in its `def_a`/`def_b` (creation)
// order — the guest asserts the provided vaults against `pool.vault_a_id` /
// `vault_b_id`, which needn't match `derive_pair`'s canonical order for a
// non-canonically created pool. (`pair.pool`/`current_tick`/`clock` are
// order-independent, so they still come from `derive_pair`.)
let Some(pool) = hex::decode(&request.pool_data)
.ok()
.and_then(|bytes| borsh::from_slice::<PoolDefinition>(&bytes).ok())
else {
return Err(String::from("no_pool"));
};
let user_input_holding =
account_id_from_hex(&request.user_input_holding_id, "user input holding id")?;
let user_output_holding =
account_id_from_hex(&request.user_output_holding_id, "user output holding id")?;
let exact_amount_out = parse_u128(&request.amount_out, "amountOut")?;
let max_amount_in = parse_u128(&request.max_in, "maxIn")?;
let deadline = parse_u64(&request.deadline_ms, "deadlineMs")?;
let instruction = risc0_zkvm::serde::to_vec(&amm_core::Instruction::SwapExactOutput {
exact_amount_out,
max_amount_in,
deadline,
})
.map_err(|error| format!("instruction serialization failed: {error}"))?;
// Fixed IDL account order for SwapExactOutput; only user_input_holding signs.
let account_ids = [
pair.config,
pair.pool,
pool.vault_a_id,
pool.vault_b_id,
user_input_holding,
user_output_holding,
pair.current_tick,
pair.clock,
];
let signing_requirements = [false, false, false, false, true, false, false, false];
Ok(json!({
"programId": request.amm_program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
}))
}
/// Computes the AMM `ProgramId` (RISC Zero Image ID) of a deployed program
/// binary. `elf` is the hex-encoded `.bin` (a RISC Zero `ProgramBinary`, not a
/// raw ELF) — decoded, image-id computed, returned as 64-char lowercase hex.
pub(super) fn program_id(request: ProgramIdRequest) -> Result<Value, String> {
let elf = hex::decode(&request.elf).map_err(|error| format!("invalid elf hex: {error}"))?;
let binary = ProgramBinary::decode(&elf).map_err(|error| format!("{error:?}"))?;
let image_id: nssa_core::program::ProgramId = binary
.compute_image_id()
.map_err(|error| format!("{error:?}"))?
.into();
Ok(json!({ "programId": hex::encode(program_id_bytes(image_id)) }))
}
#[cfg(test)]
mod tests {
use amm_core::PoolDefinition;
use nssa_core::account::AccountId;
use super::*;
use crate::account::{AccountRead, WalletAccount};
fn pool_read(pool: &PoolDefinition) -> AccountRead {
AccountRead {
id: "11".repeat(32),
status: String::from("ok"),
account: Some(WalletAccount {
program_owner: "00".repeat(32),
balance: "0".repeat(32),
nonce: "0".repeat(32),
data: hex::encode(borsh::to_vec(pool).unwrap()),
}),
}
}
#[test]
fn resolve_pool_reports_canonical_token_ids_and_derived_accounts() {
let def_a = AccountId::new([0xAA; 32]);
let def_b = AccountId::new([0xBB; 32]);
let vault_a = AccountId::new([0xC1; 32]);
let vault_b = AccountId::new([0xC2; 32]);
let lp = AccountId::new([0xD0; 32]);
let pool = PoolDefinition {
definition_token_a_id: def_a,
definition_token_b_id: def_b,
vault_a_id: vault_a,
vault_b_id: vault_b,
liquidity_pool_id: lp,
liquidity_pool_supply: 1_000,
reserve_a: 111,
reserve_b: 222,
fees: 30,
};
let value = resolve_pool(ResolvePoolRequest {
pool: pool_read(&pool),
})
.unwrap();
assert_eq!(value["status"], "ok");
assert_eq!(value["poolId"], AccountId::new([0x11; 32]).to_string());
// The Swap UI matches reserveA/reserveB to its own sell/buy direction by
// comparing the sold token's id against defAHex — so both ids must be
// present in canonical order.
assert_eq!(value["defAHex"], account_id_hex(def_a));
assert_eq!(value["defBHex"], account_id_hex(def_b));
assert_eq!(value["vaultAId"], vault_a.to_string());
assert_eq!(value["vaultBId"], vault_b.to_string());
assert_eq!(value["lpDefinitionId"], lp.to_string());
assert_eq!(value["reserveA"], "111");
assert_eq!(value["reserveB"], "222");
assert_eq!(value["liquiditySupply"], "1000");
assert_eq!(value["feeBps"], 30);
}
#[test]
fn resolve_pool_absent_is_a_no_pool_error_carrying_the_pool_id() {
let pool = PoolDefinition {
liquidity_pool_supply: 0,
..Default::default()
};
let value = resolve_pool(ResolvePoolRequest {
pool: pool_read(&pool),
})
.unwrap();
assert_eq!(
value,
json!({
"status": "error",
"error": "no_pool",
"poolId": AccountId::new([0x11; 32]).to_string(),
})
);
}
#[test]
fn same_token_pair_is_rejected_by_swap_pair_and_exact_in_plan() {
let program = "00".repeat(32);
let same = "aa".repeat(32);
// Not reached before the same-token check, so its contents don't matter.
let dummy_config = AccountRead {
id: String::new(),
status: String::from("read_failed"),
account: None,
};
let pair = swap_pair(SwapPairRequest {
amm_program_id: program.clone(),
token_in_id: same.clone(),
token_out_id: same.clone(),
config: dummy_config.clone(),
})
.unwrap();
assert_eq!(
pair,
json!({ "status": "error", "code": "same_token_pair" })
);
// The plan op routes the same domain failure through the FFI envelope (Err).
let plan_error = swap_exact_in_plan(SwapExactInPlanRequest {
amm_program_id: program,
token_in_id: same.clone(),
token_out_id: same,
config: dummy_config,
user_input_holding_id: String::new(),
user_output_holding_id: String::new(),
amount_in: String::new(),
min_out: String::new(),
deadline_ms: String::new(),
pool_data: String::new(),
})
.unwrap_err();
assert_eq!(plan_error, "same_token_pair");
}
#[test]
fn exact_in_plan_routes_config_unavailable_via_err() {
// Distinct tokens (past the same-token check) with an unavailable config:
// derive_pair fails, and the plan must surface it as Err("config_unavailable")
// — not a legacy Ok({ status, code }) shape.
let plan_error = swap_exact_in_plan(SwapExactInPlanRequest {
amm_program_id: "00".repeat(32),
token_in_id: "aa".repeat(32),
token_out_id: "bb".repeat(32),
config: AccountRead {
id: String::new(),
status: String::from("read_failed"),
account: None,
},
user_input_holding_id: String::new(),
user_output_holding_id: String::new(),
amount_in: String::new(),
min_out: String::new(),
deadline_ms: String::new(),
pool_data: String::new(),
})
.unwrap_err();
assert_eq!(plan_error, "config_unavailable");
}
#[test]
fn swap_exact_out_plan_routes_same_token_through_the_envelope() {
let program = "00".repeat(32);
let same = "aa".repeat(32);
let dummy_config = AccountRead {
id: String::new(),
status: String::from("read_failed"),
account: None,
};
let plan_error = swap_exact_out_plan(SwapExactOutPlanRequest {
amm_program_id: program,
token_in_id: same.clone(),
token_out_id: same,
config: dummy_config,
user_input_holding_id: String::new(),
user_output_holding_id: String::new(),
amount_out: String::new(),
max_in: String::new(),
deadline_ms: String::new(),
pool_data: String::new(),
})
.unwrap_err();
assert_eq!(plan_error, "same_token_pair");
}
fn pool_data_hex(pool: &PoolDefinition) -> String {
hex::encode(borsh::to_vec(pool).unwrap())
}
#[test]
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 {
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_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 {
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 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);
let a = AccountId::new([0xCC; 32]);
let b = AccountId::new([0xDD; 32]);
let ab = pool_id(PoolIdRequest {
amm_program_id: program.clone(),
token_in_id: account_id_hex(a),
token_out_id: account_id_hex(b),
})
.unwrap();
let ba = pool_id(PoolIdRequest {
amm_program_id: program.clone(),
token_in_id: account_id_hex(b),
token_out_id: account_id_hex(a),
})
.unwrap();
// Canonical ordering makes the pool id independent of swap direction.
assert_eq!(ab, ba);
// And it matches amm_core's PDA for the canonical pair.
let amm = parse_program_id(&program).unwrap();
let (ca, cb) = if is_canonical_pair(a, b) {
(a, b)
} else {
(b, a)
};
assert_eq!(ab["poolId"], account_id_hex(compute_pool_pda(amm, ca, cb)));
// Same token in/out is rejected.
assert!(pool_id(PoolIdRequest {
amm_program_id: program,
token_in_id: account_id_hex(a),
token_out_id: account_id_hex(a),
})
.is_err());
}
}