diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index 0a06723..1b3abfd 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -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); diff --git a/modules/amm/ffi/src/api/liquidity.rs b/modules/amm/ffi/src/api/liquidity.rs index 248ceed..dd66126 100644 --- a/modules/amm/ffi/src/api/liquidity.rs +++ b/modules/amm/ffi/src/api/liquidity.rs @@ -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 { .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, + signing_requirements: &[bool], + instruction: Vec, +) -> Value { + json!({ + "programId": program_id, + "accountIds": account_ids.into_iter().map(account_id_hex).collect::>(), + "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 Result Result { + 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::(&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::>(), - "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 { + 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::(&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::>(); + (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::>()) + }; + 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"))); + } } diff --git a/modules/amm/ffi/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs index 104b3a7..40bb4b9 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -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) diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index bb4f24f..46b1343 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -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 { diff --git a/modules/amm/ffi/src/ffi.rs b/modules/amm/ffi/src/ffi.rs index ee6c9d9..af6b194 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -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::(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::(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::(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::(request_json, api::token_holdings) diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index f109dec..96db944 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -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 accounts = jsonStrVec(plan.value("accountIds", json::array())); + const std::vector signers = jsonBoolVec(plan.value("signingRequirements", json::array())); + const std::vector 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(); diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index 72b32a9..fef6f13 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -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: }` — `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: }`. On failure: + /// `{ status:"error", error: }` — `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