feat(modules/amm): add the add-liquidity API and quoting

Introduce the add-liquidity vertical mirroring the swap + createPool patterns,
for depositing into an existing pool via the AddLiquidity instruction.

FFI (amm_ffi):
- add_liquidity_quote — decode poolData, orient the caller's max amounts to the
  pool's canonical order, run the guest's exact ideal->actual->delta_lp math, and
  return { amountARaw, amountBRaw, expectedLpRaw, priceRaw } in display order.
  Slippage-free like create's quote; the min-LP floor is applied at submit.
- add_liquidity_plan — canonicalize (token, max-amount, holding) as one unit,
  take vaults + LP definition from poolData, emit the 10-account AddLiquidity
  order (only the user holdings a/b/LP sign). Takes minLpRaw directly, like
  swap_exact_in_plan takes min_out.

Shared with createPool: extract canonical_triples (the pair/amount/holding
canonical swap) and plan_response (the tx-submission envelope); both the create
and add plans now use them.
This commit is contained in:
r4bbit
2026-08-11 12:18:32 +02:00
parent b1b4631234
commit e886c1d088
7 changed files with 714 additions and 20 deletions
+4
View File
@@ -44,6 +44,10 @@ char *amm_liquidity_quote(const char *request_json);
char *amm_create_pool_plan(const char *request_json);
char *amm_add_liquidity_quote(const char *request_json);
char *amm_add_liquidity_plan(const char *request_json);
char *amm_token_holdings(const char *request_json);
char *amm_program_id(const char *request_json);
+481 -12
View File
@@ -11,12 +11,16 @@
//! existence before calling; a stale preview or a raced create just reverts on the
//! guest's `assert pool uninitialized`.
use amm_core::{isqrt_product, spot_price_q64_64, MINIMUM_LIQUIDITY};
use amm_core::{
isqrt_product, mul_div_floor, spot_price_q64_64, PoolDefinition, MINIMUM_LIQUIDITY,
};
use nssa_core::account::AccountId;
use serde_json::{json, Value};
use super::{
pair::{derive_pair, is_canonical_pair},
CreatePoolPlanRequest, LiquidityQuoteRequest,
AddLiquidityPlanRequest, AddLiquidityQuoteRequest, CreatePoolPlanRequest,
LiquidityQuoteRequest,
};
use crate::account::{account_id_from_hex, account_id_hex, parse_program_id};
@@ -42,6 +46,42 @@ fn parse_u64(value: &str, label: &str) -> Result<u64, String> {
.map_err(|error| format!("invalid {label}: {error}"))
}
/// Canonicalizes a token pair and moves each token's paired `(amount, holding)` with it, so
/// the returned `a` side is the canonical token-a — lining up with `derive_pair`'s canonical
/// `vault_a`. The guest derives `vault_a` from `user_holding_a`'s definition and debits
/// `amount_a` into it, so the `(token, amount, holding)` triple must stay together (see
/// `create_pool_plan`). Shared by the create and add plans.
fn canonical_triples(
token_a: AccountId,
token_b: AccountId,
amount_a: u128,
amount_b: u128,
holding_a: AccountId,
holding_b: AccountId,
) -> (AccountId, AccountId, u128, u128, AccountId, AccountId) {
if is_canonical_pair(token_a, token_b) {
(token_a, token_b, amount_a, amount_b, holding_a, holding_b)
} else {
(token_b, token_a, amount_b, amount_a, holding_b, holding_a)
}
}
/// The tx-submission envelope shared by the create and add plans: the fixed IDL account ids
/// as hex, their signer flags, and the risc0-encoded instruction words.
fn plan_response(
program_id: &str,
account_ids: impl IntoIterator<Item = AccountId>,
signing_requirements: &[bool],
instruction: Vec<u32>,
) -> Value {
json!({
"programId": program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
})
}
/// Prices a create-pool deposit: the LP the creator receives and the opening price.
///
/// Pure — no chain reads. The fee tier is not needed: it is not part of the pool PDA
@@ -116,13 +156,8 @@ pub(super) fn create_pool_plan(request: CreatePoolPlanRequest) -> Result<Value,
// Canonical orientation: (token, amount, holding) all move together, so user_a is
// the canonical token-a holding and canonical_amount_a its deposit — matching the
// canonical vault_a derive_pair returns (see the doc comment).
let reversed = !is_canonical_pair(token_a, token_b);
let (canonical_a, canonical_b, canonical_amount_a, canonical_amount_b, user_a, user_b) =
if reversed {
(token_b, token_a, amount_b, amount_a, holding_b, holding_a)
} else {
(token_a, token_b, amount_a, amount_b, holding_a, holding_b)
};
canonical_triples(token_a, token_b, amount_a, amount_b, holding_a, holding_b);
let Ok(pair) = derive_pair(amm_program, canonical_a, canonical_b, &request.config) else {
return Err(String::from("config_unavailable"));
@@ -154,14 +189,184 @@ pub(super) fn create_pool_plan(request: CreatePoolPlanRequest) -> Result<Value,
false, false, false, false, false, false, true, true, true, false, false,
];
Ok(plan_response(
&request.amm_program_id,
account_ids,
&signing_requirements,
instruction,
))
}
/// Prices an `AddLiquidity` into an existing pool. Mirrors the swap quotes: decode the
/// pool from `pool_data` (absent / undecodable / zero-supply ⇒ `no_pool`), orient the
/// 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).
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")?;
if token_a == token_b {
return Err(String::from("same_token_pair"));
}
let max_a = positive_amount(Some(&request.max_amount_a_raw))?;
let max_b = positive_amount(Some(&request.max_amount_b_raw))?;
// Decode the pool; absent / undecodable / empty ⇒ nothing to add to.
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 the caller's (display) max amounts to the pool's canonical (a, b) order so
// the math lines up with the guest; `reversed` also flips the results back to display.
let reversed = if token_a == pool.definition_token_a_id && token_b == pool.definition_token_b_id
{
false
} else if token_a == pool.definition_token_b_id && token_b == pool.definition_token_a_id {
true
} else {
return Err(String::from("pair_mismatch"));
};
if pool.reserve_a == 0 || pool.reserve_b == 0 {
return Err(String::from("no_pool"));
}
let (max_canonical_a, max_canonical_b) = if reversed {
(max_b, max_a)
} else {
(max_a, max_b)
};
// Guest math (amm_program::add::add_liquidity): proportional deposit clamped to the
// caller's maxes, then the LP minted for the smaller side.
let ideal_a = mul_div_floor(pool.reserve_a, max_canonical_b, pool.reserve_b);
let ideal_b = mul_div_floor(pool.reserve_b, max_canonical_a, pool.reserve_a);
let actual_a = ideal_a.min(max_canonical_a);
let actual_b = ideal_b.min(max_canonical_b);
let delta_lp = std::cmp::min(
mul_div_floor(pool.liquidity_pool_supply, actual_a, pool.reserve_a),
mul_div_floor(pool.liquidity_pool_supply, actual_b, pool.reserve_b),
);
if actual_a == 0 || actual_b == 0 || delta_lp == 0 {
return Err(String::from("amount_too_low"));
}
// 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)
} else {
(actual_a, actual_b)
};
let (reserve_display_a, reserve_display_b) = if reversed {
(pool.reserve_b, pool.reserve_a)
} else {
(pool.reserve_a, pool.reserve_b)
};
let price = spot_price_q64_64(reserve_display_a, reserve_display_b);
Ok(json!({
"programId": request.amm_program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
"amountARaw": display_a.to_string(),
"amountBRaw": display_b.to_string(),
"expectedLpRaw": delta_lp.to_string(),
"priceRaw": price.to_string(),
}))
}
/// Builds the `AddLiquidity` submission for an existing pool. Same canonicalization as
/// `create_pool_plan` — the `(token, max_amount, holding)` triples move together so `user_a`
/// / `max_canonical_a` line up with the pool's canonical `vault_a`. Vaults and the LP
/// definition come from the pool's STORED ids (`pool_data`), which the guest asserts against
/// (like the swap plans). Emits the fixed 10-account IDL order with only the three user
/// holdings (a, b, LP) signing. `min_amount_liquidity` is the caller's slippage floor and
/// must be positive (the guest rejects a zero). Recoverable failures fail closed as `Err`
/// (`same_token_pair`, `config_unavailable`, `no_pool`, bad amounts).
pub(super) fn add_liquidity_plan(request: AddLiquidityPlanRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
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")?;
if token_a == token_b {
return Err(String::from("same_token_pair"));
}
let holding_a = account_id_from_hex(&request.user_holding_a_id, "user holding A id")?;
let holding_b = account_id_from_hex(&request.user_holding_b_id, "user holding B id")?;
let user_lp = account_id_from_hex(&request.user_holding_lp_id, "user LP holding id")?;
let max_a = positive_amount(Some(&request.max_amount_a_raw))?;
let max_b = positive_amount(Some(&request.max_amount_b_raw))?;
let min_lp = positive_amount(Some(&request.min_lp_raw))?;
let deadline = parse_u64(&request.deadline_ms, "deadlineMs")?;
// config / pool / current_tick / clock are order-independent PDAs, so derive_pair takes the
// tokens in the caller's order. (Its canonical vaults are unused here — the guest asserts the
// vaults against the pool's stored ids, taken below.)
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Err(String::from("config_unavailable"));
};
// Vaults + LP definition come from the pool's stored ids (the guest asserts against them).
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"));
};
// Orient (max amount, holding) to the pool's STORED (definition_token_a_id,
// definition_token_b_id) order — NOT is_canonical_pair. The guest transfers user_holding_a
// into vault_a (== pool.vault_a_id, which holds definition_token_a_id), so user_a / max_pool_a
// must be that token's holding / cap. A pool created outside the FFI (e.g. a non-canonical
// `spel new-definition`) can store the opposite order, so keying off is_canonical_pair would
// send a holding into the wrong vault and the token program rejects the transfer on a
// sender/recipient definition mismatch.
let (max_pool_a, max_pool_b, user_a, user_b) =
if token_a == pool.definition_token_a_id && token_b == pool.definition_token_b_id {
(max_a, max_b, holding_a, holding_b)
} else if token_a == pool.definition_token_b_id && token_b == pool.definition_token_a_id {
(max_b, max_a, holding_b, holding_a)
} else {
return Err(String::from("pair_mismatch"));
};
let instruction = risc0_zkvm::serde::to_vec(&amm_core::Instruction::AddLiquidity {
min_amount_liquidity: min_lp,
max_amount_to_add_token_a: max_pool_a,
max_amount_to_add_token_b: max_pool_b,
deadline,
})
.map_err(|error| format!("instruction serialization failed: {error}"))?;
// Fixed IDL account order for AddLiquidity; only the user holdings (a, b, LP) sign.
let account_ids = [
pair.config,
pair.pool,
pool.vault_a_id,
pool.vault_b_id,
pool.liquidity_pool_id,
user_a,
user_b,
user_lp,
pair.current_tick,
pair.clock,
];
let signing_requirements = [
false, false, false, false, false, true, true, true, false, false,
];
Ok(plan_response(
&request.amm_program_id,
account_ids,
&signing_requirements,
instruction,
))
}
#[cfg(test)]
mod tests {
use amm_core::{compute_config_pda, compute_pool_pda, compute_vault_pda, AmmConfig};
@@ -356,4 +561,268 @@ mod tests {
});
assert_eq!(value, Err(String::from("same_token_pair")));
}
fn pool_hex(pool: &PoolDefinition) -> String {
hex::encode(borsh::to_vec(pool).unwrap())
}
#[test]
fn add_quote_prices_via_guest_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()
};
// Display == canonical. maxB is generous, so token A's cap binds and token B is
// ratio-matched down to 20_000 (proving the ideal→actual clamp).
let ab = 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("100000"),
pool_data: pool_hex(&pool),
})
.unwrap();
assert_eq!(ab["amountARaw"], "10000");
assert_eq!(ab["amountBRaw"], "20000");
assert_eq!(ab["expectedLpRaw"], "10000");
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());
// Reverse display order: the actual amounts and the price flip to display order.
let ba = add_liquidity_quote(AddLiquidityQuoteRequest {
token_a_id: account_id_hex(def_b),
token_b_id: account_id_hex(def_a),
max_amount_a_raw: String::from("100000"),
max_amount_b_raw: String::from("10000"),
pool_data: pool_hex(&pool),
})
.unwrap();
assert_eq!(ba["amountARaw"], "20000"); // display token def_b side
assert_eq!(ba["amountBRaw"], "10000"); // display token def_a side
assert_eq!(ba["expectedLpRaw"], "10000");
assert_eq!(
ba["priceRaw"],
spot_price_q64_64(2_000_000, 1_000_000).to_string()
);
}
#[test]
fn add_quote_rejects_no_pool_mismatch_and_tiny_deposits() {
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 =
|token_a: AccountId, token_b: AccountId, max_a: &str, max_b: &str, data: String| {
AddLiquidityQuoteRequest {
token_a_id: account_id_hex(token_a),
token_b_id: account_id_hex(token_b),
max_amount_a_raw: max_a.into(),
max_amount_b_raw: max_b.into(),
pool_data: data,
}
};
// Same token pair.
assert_eq!(
add_liquidity_quote(req(def_a, def_a, "1", "1", pool_hex(&pool))),
Err(String::from("same_token_pair"))
);
// Empty / undecodable pool data.
assert_eq!(
add_liquidity_quote(req(def_a, def_b, "1", "1", String::new())),
Err(String::from("no_pool"))
);
// Zero-supply pool.
let empty = PoolDefinition {
definition_token_a_id: def_a,
definition_token_b_id: def_b,
liquidity_pool_supply: 0,
..Default::default()
};
assert_eq!(
add_liquidity_quote(req(def_a, def_b, "1", "1", pool_hex(&empty))),
Err(String::from("no_pool"))
);
// A decoded pool that isn't for this pair.
let other = AccountId::new([0xCC; 32]);
assert_eq!(
add_liquidity_quote(req(def_a, other, "1", "1", pool_hex(&pool))),
Err(String::from("pair_mismatch"))
);
// Deposits so small the minted LP rounds to zero.
assert_eq!(
add_liquidity_quote(req(def_a, def_b, "1", "1", pool_hex(&pool))),
Err(String::from("amount_too_low"))
);
}
#[test]
fn add_plan_orients_holdings_to_the_pools_stored_order() {
let program = "00".repeat(32);
let amm = parse_program_id(&program).unwrap();
// token_b is is_canonical_pair's "canonical a" (larger id), but the pool was created in
// the OPPOSITE order — definition_token_a_id = token_a — as a pool created outside the
// FFI can be (e.g. the testnet setup's `spel new-definition`). The plan must follow the
// POOL's stored order, NOT is_canonical_pair, or a holding lands in the wrong vault and
// the token program rejects the transfer on a sender/recipient definition mismatch.
let token_a = AccountId::new([0x11; 32]);
let token_b = AccountId::new([0x22; 32]);
assert!(!is_canonical_pair(token_a, token_b)); // is_canonical_pair's canonical-a is token_b
let vault_a = AccountId::new([0xA1; 32]); // vault for token_a
let vault_b = AccountId::new([0xB1; 32]); // vault for token_b
let lp_def = AccountId::new([0xCC; 32]);
let pool = PoolDefinition {
definition_token_a_id: token_a, // stored NON-canonically (token_a first)
definition_token_b_id: token_b,
vault_a_id: vault_a,
vault_b_id: vault_b,
liquidity_pool_id: lp_def,
liquidity_pool_supply: 1_000_000,
reserve_a: 1_000_000,
reserve_b: 2_000_000,
fees: 30,
};
let holding_a = AccountId::new([0x0A; 32]); // token_a holding
let holding_b = AccountId::new([0x0B; 32]); // token_b holding
let lp = AccountId::new([0x0C; 32]);
// Run the plan for a caller ordering; returns (accountIds, instruction words).
let run = |ta: String, tb: String, ma: &str, mb: &str, ha: String, hb: String| {
let value = add_liquidity_plan(AddLiquidityPlanRequest {
amm_program_id: program.clone(),
config: valid_config(amm),
token_a_id: ta,
token_b_id: tb,
max_amount_a_raw: ma.to_string(),
max_amount_b_raw: mb.to_string(),
min_lp_raw: String::from("500"),
deadline_ms: String::from("1000"),
user_holding_a_id: ha,
user_holding_b_id: hb,
user_holding_lp_id: account_id_hex(lp),
pool_data: pool_hex(&pool),
})
.unwrap();
let ids = value["accountIds"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<String>>();
(ids, value["instruction"].clone())
};
// The instruction the guest must receive: token_a's cap into vault_a (token_a), token_b's
// cap into vault_b — regardless of the caller's argument order.
let expected_instruction = {
let words = risc0_zkvm::serde::to_vec(&amm_core::Instruction::AddLiquidity {
min_amount_liquidity: 500,
max_amount_to_add_token_a: 1_000_000, // token_a's cap
max_amount_to_add_token_b: 4_000_000, // token_b's cap
deadline: 1_000,
})
.unwrap();
serde_json::json!(words.iter().map(|w| u64::from(*w)).collect::<Vec<u64>>())
};
let assert_aligned = |ids: &[String], instruction: &serde_json::Value| {
assert_eq!(ids[0], account_id_hex(compute_config_pda(amm)));
assert_eq!(
ids[1],
account_id_hex(compute_pool_pda(amm, token_a, token_b))
);
assert_eq!(ids[2], account_id_hex(vault_a));
assert_eq!(ids[3], account_id_hex(vault_b));
assert_eq!(ids[4], account_id_hex(lp_def));
// user_holding_a is token_a's holding — the token vault_a holds — NOT the
// is_canonical_pair canonical-a (token_b) holding.
assert_eq!(ids[5], account_id_hex(holding_a));
assert_eq!(ids[6], account_id_hex(holding_b));
assert_eq!(ids[7], account_id_hex(lp));
assert_eq!(instruction, &expected_instruction);
};
// Caller order == the pool's stored order → NO swap (is_canonical_pair WOULD swap here).
let (ids, instruction) = run(
account_id_hex(token_a),
account_id_hex(token_b),
"1000000",
"4000000",
account_id_hex(holding_a),
account_id_hex(holding_b),
);
assert_aligned(&ids, &instruction);
// Caller order reversed vs the pool → SWAP, so user_a stays token_a's (vault_a's) holding
// and each cap follows its token.
let (ids, instruction) = run(
account_id_hex(token_b),
account_id_hex(token_a),
"4000000",
"1000000",
account_id_hex(holding_b),
account_id_hex(holding_a),
);
assert_aligned(&ids, &instruction);
}
#[test]
fn add_plan_fails_closed() {
let token = AccountId::new([0xAA; 32]);
let other = AccountId::new([0xBB; 32]);
let base =
|token_a: AccountId, token_b: AccountId, pool_data: String| AddLiquidityPlanRequest {
amm_program_id: "00".repeat(32),
config: read_failed(),
token_a_id: account_id_hex(token_a),
token_b_id: account_id_hex(token_b),
max_amount_a_raw: String::from("1"),
max_amount_b_raw: String::from("1"),
min_lp_raw: String::from("1"),
deadline_ms: String::from("1"),
user_holding_a_id: account_id_hex(token_a),
user_holding_b_id: account_id_hex(token_b),
user_holding_lp_id: account_id_hex(token_a),
pool_data,
};
// Same token pair — rejected before any config/pool work.
assert_eq!(
add_liquidity_plan(base(token, token, String::new())),
Err(String::from("same_token_pair"))
);
// Unavailable config (read_failed) surfaces before the pool decode.
assert_eq!(
add_liquidity_plan(base(token, other, String::new())),
Err(String::from("config_unavailable"))
);
// Valid config but no pool data → no_pool (decode happens after derive_pair).
let amm = parse_program_id(&"00".repeat(32)).unwrap();
let mut no_pool = base(token, other, String::new());
no_pool.config = valid_config(amm);
assert_eq!(add_liquidity_plan(no_pool), Err(String::from("no_pool")));
}
}
+12 -3
View File
@@ -23,9 +23,10 @@ mod tests;
use std::{error::Error, fmt};
pub use request::{
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest,
PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest,
ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
AddLiquidityPlanRequest, AddLiquidityQuoteRequest, ConfigIdRequest, ContextRequest,
CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PairSnapshot, PlanRequest,
PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest,
SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest,
};
use serde_json::Value;
@@ -141,6 +142,14 @@ pub fn create_pool_plan(request: CreatePoolPlanRequest) -> AmmResult {
liquidity::create_pool_plan(request).map_err(Into::into)
}
pub fn add_liquidity_quote(request: AddLiquidityQuoteRequest) -> AmmResult {
liquidity::add_liquidity_quote(request).map_err(Into::into)
}
pub fn add_liquidity_plan(request: AddLiquidityPlanRequest) -> AmmResult {
liquidity::add_liquidity_plan(request).map_err(Into::into)
}
/// Lists the wallet's fungible token holdings for the account selector.
pub fn token_holdings(request: TokenHoldingsRequest) -> AmmResult {
token_holdings::token_holdings(request).map_err(Into::into)
+37
View File
@@ -168,6 +168,43 @@ pub struct CreatePoolPlanRequest {
pub user_holding_lp_id: String,
}
/// Prices an `AddLiquidity` into an existing pool — the add counterpart of
/// `LiquidityQuoteRequest`. The two max amounts are the caller's caps (display order);
/// `pool_data` is the hex Borsh `PoolDefinition` (empty ⇒ no pool), same as the swap quotes.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AddLiquidityQuoteRequest {
pub token_a_id: String,
pub token_b_id: String,
pub max_amount_a_raw: String,
pub max_amount_b_raw: String,
pub pool_data: String,
}
/// Builds the `AddLiquidity` submission — the add counterpart of `CreatePoolPlanRequest`.
/// `min_lp_raw` is the caller's slippage floor on the LP minted (the guest's
/// `min_amount_liquidity`, applied at submit like the swap plans' `min_out`); `pool_data`
/// supplies the stored vault / LP-definition ids the guest asserts against.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AddLiquidityPlanRequest {
/// Resolved by the module from `AMM_PROGRAM_BIN` (like every id-deriving op).
pub amm_program_id: String,
/// AMM config account read — decoded by `derive_pair` for the `twap_oracle_program_id`
/// the `current_tick` PDA depends on (same as the swap / create plan requests).
pub config: AccountRead,
pub token_a_id: String,
pub token_b_id: String,
pub max_amount_a_raw: String,
pub max_amount_b_raw: String,
pub min_lp_raw: String,
pub deadline_ms: String,
pub user_holding_a_id: String,
pub user_holding_b_id: String,
pub user_holding_lp_id: String,
pub pool_data: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TokenHoldingsRequest {
+15 -5
View File
@@ -6,11 +6,11 @@ use std::{
use serde::{de::DeserializeOwned, Serialize};
use crate::api::{
self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, CreatePoolPlanRequest,
LiquidityQuoteRequest, PairIdsRequest, PlanRequest, PoolIdRequest, ProgramIdRequest,
QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest,
SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest,
TokenIdsRequest,
self, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, AmmApiError, AmmResult,
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest,
PlanRequest, PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest,
SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest,
};
#[derive(Serialize)]
@@ -154,6 +154,16 @@ pub extern "C" fn amm_create_pool_plan(request_json: *const c_char) -> *mut c_ch
call::<CreatePoolPlanRequest>(request_json, api::create_pool_plan)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_add_liquidity_quote(request_json: *const c_char) -> *mut c_char {
call::<AddLiquidityQuoteRequest>(request_json, api::add_liquidity_quote)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_add_liquidity_plan(request_json: *const c_char) -> *mut c_char {
call::<AddLiquidityPlanRequest>(request_json, api::add_liquidity_plan)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_token_holdings(request_json: *const c_char) -> *mut c_char {
call::<TokenHoldingsRequest>(request_json, api::token_holdings)
+140
View File
@@ -876,6 +876,146 @@ LogosMap AmmModuleImpl::createPool(const LogosMap& request) {
return LogosMap{{"status", "ok"}, {"error", ""}, {"transactionId", jStr(obj, "tx_hash")}};
}
LogosMap AmmModuleImpl::addLiquidityQuote(const LogosMap& request) {
auto error = [](const std::string& err) {
return LogosMap{{"status", "error"}, {"error", err}};
};
// Normalize the pair to hex (the liquidity UI still sources base58 ids; transitional).
const std::string token_a = normalizeAccountId(jStr(request, "tokenAId"));
const std::string token_b = normalizeAccountId(jStr(request, "tokenBId"));
if (token_a.empty() || token_b.empty())
return error("invalid_token_id");
const std::string amm_program_id = ammProgramId();
if (amm_program_id.empty())
return error("config_missing");
std::string max_a_decimal;
std::string max_b_decimal;
if (!jsonAmountToDecimal(request.value("maxAmountARaw", json()), max_a_decimal)
|| !jsonAmountToDecimal(request.value("maxAmountBRaw", json()), max_b_decimal))
return error("bad_amount");
// 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_a},
{"tokenOutId", token_b},
});
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_add_liquidity_quote, json{
{"tokenAId", token_a},
{"tokenBId", token_b},
{"maxAmountARaw", max_a_decimal},
{"maxAmountBRaw", max_b_decimal},
{"poolData", pool_data},
});
if (!quoteResult.ok)
return error(quoteResult.error.empty() ? "backend_error" : quoteResult.error);
// Success: wrap { amountARaw, amountBRaw, expectedLpRaw, priceRaw } in the envelope.
LogosMap out = quoteResult.value;
out["status"] = "ok";
out["error"] = "";
return out;
}
LogosMap AmmModuleImpl::addLiquidity(const LogosMap& request) {
auto error = [](const std::string& err) {
return LogosMap{{"status", "error"}, {"error", err}};
};
// config_missing == no program id from AMM_PROGRAM_BIN (same as createPool).
const std::string amm_program_id = ammProgramId();
if (amm_program_id.empty())
return error("config_missing");
// amm_add_liquidity_plan needs the config account for the twap program id the
// current-tick PDA derives from; a bad/absent config surfaces from the plan.
const FfiResult configResult =
call(amm_config_id, json{{"ammProgramId", amm_program_id}});
if (!configResult.ok)
return error("backend_error");
const json config = readPublicAccount(jStr(configResult.value, "configId"));
// Normalize the pair + user holdings (incl. the LP holding that receives the minted LP)
// to hex (base58 tolerated — transitional).
const std::string token_a = normalizeAccountId(jStr(request, "tokenAId"));
const std::string token_b = normalizeAccountId(jStr(request, "tokenBId"));
const std::string holding_a = normalizeAccountId(jStr(request, "holdingAId"));
const std::string holding_b = normalizeAccountId(jStr(request, "holdingBId"));
const std::string user_lp = normalizeAccountId(jStr(request, "lpHoldingId"));
if (token_a.empty() || token_b.empty() || holding_a.empty() || holding_b.empty()
|| user_lp.empty())
return error("invalid_account_id");
std::string max_a_decimal;
std::string max_b_decimal;
std::string min_lp_decimal;
std::string deadline_decimal;
if (!jsonAmountToDecimal(request.value("maxAmountARaw", json()), max_a_decimal)
|| !jsonAmountToDecimal(request.value("maxAmountBRaw", json()), max_b_decimal)
|| !jsonAmountToDecimal(request.value("minLpRaw", json()), min_lp_decimal)
|| !jsonAmountToDecimal(request.value("deadlineMs", json()), deadline_decimal))
return error("bad_amount");
// Read the pool so the plan can use its stored vault / LP-definition ids (the guest
// asserts the provided vaults/LP against them — see amm_add_liquidity_plan).
const FfiResult poolId = call(amm_pool_id, json{
{"ammProgramId", amm_program_id},
{"tokenInId", token_a},
{"tokenOutId", token_b},
});
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");
// amm_add_liquidity_plan resolves the pool accounts (canonicalizing the pair), encodes
// AddLiquidity (with the slippage floor), and returns a ready-to-submit plan.
const FfiResult planResult = call(amm_add_liquidity_plan, json{
{"ammProgramId", amm_program_id},
{"config", config},
{"tokenAId", token_a},
{"tokenBId", token_b},
{"maxAmountARaw", max_a_decimal},
{"maxAmountBRaw", max_b_decimal},
{"minLpRaw", min_lp_decimal},
{"deadlineMs", deadline_decimal},
{"userHoldingAId", holding_a},
{"userHoldingBId", holding_b},
{"userHoldingLpId", user_lp},
{"poolData", pool_data},
});
if (!planResult.ok)
return error(planResult.error.empty() ? "backend_error" : planResult.error);
const json plan = planResult.value;
const std::vector<std::string> accounts = jsonStrVec(plan.value("accountIds", json::array()));
const std::vector<bool> signers = jsonBoolVec(plan.value("signingRequirements", json::array()));
const std::vector<uint8_t> instruction = jsonWordsToLeBytes(plan.value("instruction", json::array()));
const std::string program_id = jStr(plan, "programId");
AMM_TRACE("addLiquidity: SUBMIT programId=" << program_id
<< " instrBytes=" << instruction.size() << " accounts=" << accounts.size());
const std::string reply = modules().logos_execution_zone.send_generic_public_transaction(
accounts, signers, instruction, program_id);
AMM_TRACE("addLiquidity: tx reply=" << reply);
const auto obj = json::parse(reply, nullptr, /*allow_exceptions=*/false);
if (!obj.is_object() || !obj.value("success", false))
return error("wallet_submission_failed");
return LogosMap{{"status", "ok"}, {"error", ""}, {"transactionId", jStr(obj, "tx_hash")}};
}
LogosList AmmModuleImpl::tokenList() {
LogosList out = LogosList::array();
+25
View File
@@ -134,6 +134,31 @@ public:
/// a code so the create-pool UI can tell the user why.
LogosMap createPool(const LogosMap& request);
/// Prices an `AddLiquidity` into the existing pool for (tokenAId, tokenBId) from the
/// two max deposit amounts. Reads the pool server-side (like the swap quotes) and runs
/// the guest's proportional-deposit math. Returns the same shape as `liquidityQuote`
/// minus the create-only locked LP: `{ status:"ok", error:"", amountARaw, amountBRaw,
/// expectedLpRaw, priceRaw }` — the actual ratio-matched deposits (display order), the
/// LP minted, and the pool's spot price. Slippage is applied at submit, not here.
/// `request` carries `{ tokenAId, tokenBId, maxAmountARaw, maxAmountBRaw }` (ids hex or
/// base58, normalized to hex; amounts a JSON integer or decimal string). On failure:
/// `{ status:"error", error:<code> }` — `invalid_token_id`, `config_missing`,
/// `bad_amount`, `no_pool`, `pair_mismatch`, `amount_too_low`, or `backend_error`.
LogosMap addLiquidityQuote(const LogosMap& request);
/// Submits an `AddLiquidity` transaction into the request's pool. `request` carries
/// `{ tokenAId, tokenBId, holdingAId, holdingBId, lpHoldingId, maxAmountARaw,
/// maxAmountBRaw, minLpRaw, deadlineMs }` (ids hex or base58, normalized to hex;
/// amounts/deadline a JSON integer or decimal string). `minLpRaw` is the caller's
/// slippage floor on the LP minted (the UI derives it from the quote's expectedLpRaw
/// and its slippage control). `lpHoldingId` is the holding that receives the minted LP.
/// On success: `{ status:"ok", error:"", transactionId:<hex tx hash> }`. On failure:
/// `{ status:"error", error:<code> }` — `config_missing`, `backend_error`,
/// `invalid_account_id`, `bad_amount`, `same_token_pair` (from `amm_pool_id` or the plan),
/// `wallet_submission_failed`, or a plan code (e.g. `no_pool`, `pair_mismatch`,
/// `config_unavailable`).
LogosMap addLiquidity(const LogosMap& request);
/// Lists the connected wallet's fungible token holdings for the account
/// selector: `[{ accountId (hex), accountType:"TokenHolding", definitionId
/// (base58), definitionIdHex (hex), balanceRaw }]` — one row per holding