From 526d50bff1d6534c6f684655f79822c45e092cc6 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:56:37 +0200 Subject: [PATCH] feat(modules/amm): add createPool quote + plan ops and module methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring pool creation onto the redesigned lean module surface mirroring the shipped swap vertical. FFI (amm_ffi): - amm_liquidity_quote: a pure create-pool preview from the two deposit amounts — expectedLpRaw / initialPriceRaw / lockedLpRaw via the shared amm_core opening-LP math (isqrt_product, MINIMUM_LIQUIDITY, spot_price_q64_64), so the preview equals what new_definition mints. No chain reads, no quoteHash, and no fee input (the fee is neither part of the pool PDA nor the pricing — one pool per pair). - amm_create_pool_plan: canonicalizes the pair, moving amounts and user holdings as one unit so each (vault, holding, amount) triple names the same token, then emits the fixed 11-account NewDefinition plan (only the user a/b and fresh LP holdings sign). Module (AmmModuleImpl): - liquidityQuote(request): thin preview wrapper, normalizes ids to hex. - createPool(request, fresh_lp_id): a new pool always needs a fresh LP holding, so an empty fresh_lp_id returns requiresFreshLp without submitting; otherwise builds the plan and submits, returning a hex transactionId. --- modules/amm/ffi/include/amm_ffi.h | 4 + modules/amm/ffi/src/api/liquidity.rs | 359 +++++++++++++++++++++++++++ modules/amm/ffi/src/api/mod.rs | 19 +- modules/amm/ffi/src/api/request.rs | 33 +++ modules/amm/ffi/src/ffi.rs | 18 +- modules/amm/ffi/src/lib.rs | 14 +- modules/amm/ffi/tests/public_api.rs | 22 +- modules/amm/src/amm_module_impl.cpp | 130 ++++++++++ modules/amm/src/amm_module_impl.h | 33 +++ 9 files changed, 616 insertions(+), 16 deletions(-) create mode 100644 modules/amm/ffi/src/api/liquidity.rs diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index a3d2b5e..3cd866c 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -40,6 +40,10 @@ char *amm_swap_exact_in_plan(const char *request_json); char *amm_swap_exact_out_plan(const char *request_json); +char *amm_liquidity_quote(const char *request_json); + +char *amm_create_pool_plan(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 new file mode 100644 index 0000000..248ceed --- /dev/null +++ b/modules/amm/ffi/src/api/liquidity.rs @@ -0,0 +1,359 @@ +//! Liquidity operations — pool-creation quoting and the `NewDefinition` submission +//! plan. Same lean, transport-independent pattern as `swap.rs`: pure functions +//! returning JSON `Value`, and the token pair canonicalized server-side so callers +//! keep no ordering logic. +//! +//! `liquidity_quote` is a **pure create-pool preview**: a function of the caller's +//! own inputs (the two deposit amounts) with no chain reads and no commitment +//! — it prices the opening LP and price via the same `amm_core` primitives the guest +//! runs (`isqrt_product`, `MINIMUM_LIQUIDITY`, `spot_price_q64_64`), so the preview +//! equals what `new_definition` mints. The caller decides create-vs-add by pool +//! 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 serde_json::{json, Value}; + +use super::{ + pair::{derive_pair, is_canonical_pair}, + CreatePoolPlanRequest, LiquidityQuoteRequest, +}; +use crate::account::{account_id_from_hex, account_id_hex, parse_program_id}; + +/// Parses a required, strictly-positive base-unit amount. Empty / non-digit / +/// zero inputs surface as stable codes so the UI can flag the offending field. +fn positive_amount(value: Option<&str>) -> Result { + let value = value + .filter(|raw| !raw.is_empty()) + .ok_or("amount_required")?; + if !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(String::from("invalid_raw_amount")); + } + let amount = value.parse::().map_err(|_| "invalid_raw_amount")?; + if amount == 0 { + return Err(String::from("amount_must_be_positive")); + } + Ok(amount) +} + +fn parse_u64(value: &str, label: &str) -> Result { + value + .parse::() + .map_err(|error| format!("invalid {label}: {error}")) +} + +/// 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 +/// (`compute_pool_pda_seed` hashes only the pair) and does not enter the pricing — +/// only the two deposit amounts do. `expected_lp = floor(sqrt(a*b)) - MINIMUM_LIQUIDITY` +/// (the post-permanent-lock remainder the guest mints to the creator); `initialPriceRaw` +/// is the `Q64.64` display price (token B per token A, in the caller's order). The LP +/// figure is orientation-independent (the product is symmetric); the price follows the +/// display order. Errors are stable short codes: `same_token_pair`, `amount_required`, +/// `invalid_raw_amount`, `amount_must_be_positive`, `amount_too_low` (deposits too small +/// to clear the locked minimum — the pool can't open). +pub(super) fn liquidity_quote(request: LiquidityQuoteRequest) -> 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 amount_a = positive_amount(request.amount_a_raw.as_deref())?; + let amount_b = positive_amount(request.amount_b_raw.as_deref())?; + + // LP math (shared with the guest's new_definition via amm_core): the initial LP + // must clear the permanently-locked minimum before the creator receives any. + let initial_lp = isqrt_product(amount_a, amount_b); + let expected_lp = initial_lp + .checked_sub(MINIMUM_LIQUIDITY) + .filter(|user_lp| *user_lp > 0) + .ok_or("amount_too_low")?; + // Display-order price: token B per token A (the caller's orientation). + let initial_price = spot_price_q64_64(amount_a, amount_b); + + Ok(json!({ + "amountARaw": amount_a.to_string(), + "amountBRaw": amount_b.to_string(), + "expectedLpRaw": expected_lp.to_string(), + "lockedLpRaw": MINIMUM_LIQUIDITY.to_string(), + "initialPriceRaw": initial_price.to_string(), + })) +} + +/// Builds the `NewDefinition` submission for creating a pool. +/// +/// The pool PDA is order-independent (`compute_pool_pda_seed` sorts the pair +/// internally), but `derive_pair` yields the vaults / current-tick in **canonical** +/// order (`vault_a` = the larger token id's vault). The guest, in turn, derives +/// `vault_a` from `user_holding_a`'s definition and transfers `token_a_amount` out +/// of it — so the `(vault_a, user_holding_a, token_a_amount)` triple must all name +/// the same token or balances land in the wrong vault. This op therefore +/// canonicalizes the pair and moves its amounts / user holdings **as one unit**, so +/// `user_a` is the canonical token-a holding, `canonical_amount_a` its deposit, and +/// `pair.vault_a` its vault. Returns the fixed 11-account IDL order with only the +/// three user holdings (a, b, LP) signing. Recoverable failures fail closed as `Err` +/// (`same_token_pair`, `config_unavailable`, bad amounts) so the caller never +/// submits an empty plan. +pub(super) fn create_pool_plan(request: CreatePoolPlanRequest) -> 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 amount_a = positive_amount(request.amount_a_raw.as_deref())?; + let amount_b = positive_amount(request.amount_b_raw.as_deref())?; + if !amm_core::is_supported_fee_tier(u128::from(request.fee_bps)) { + return Err(String::from("invalid_fee_tier")); + } + let deadline = parse_u64(&request.deadline_ms, "deadlineMs")?; + // 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) + }; + + let Ok(pair) = derive_pair(amm_program, canonical_a, canonical_b, &request.config) else { + return Err(String::from("config_unavailable")); + }; + + let instruction = risc0_zkvm::serde::to_vec(&amm_core::Instruction::NewDefinition { + token_a_amount: canonical_amount_a, + token_b_amount: canonical_amount_b, + fees: u128::from(request.fee_bps), + deadline, + }) + .map_err(|error| format!("instruction serialization failed: {error}"))?; + + // Fixed IDL account order for NewDefinition; only the user holdings (a, b, LP) sign. + let account_ids = [ + pair.config, + pair.pool, + pair.vault_a, + pair.vault_b, + pair.lp_definition, + pair.lp_lock_holding, + user_a, + user_b, + user_lp, + pair.current_tick, + pair.clock, + ]; + let signing_requirements = [ + false, false, false, false, false, false, true, true, true, false, false, + ]; + + Ok(json!({ + "programId": request.amm_program_id, + "accountIds": account_ids.into_iter().map(account_id_hex).collect::>(), + "signingRequirements": signing_requirements, + "instruction": instruction, + })) +} + +#[cfg(test)] +mod tests { + use amm_core::{compute_config_pda, compute_pool_pda, compute_vault_pda, AmmConfig}; + use nssa_core::account::{Account, AccountId, Data}; + + use super::*; + use crate::account::{account_read, AccountRead}; + + fn quote_request(token_a: AccountId, token_b: AccountId) -> LiquidityQuoteRequest { + LiquidityQuoteRequest { + token_a_id: account_id_hex(token_a), + token_b_id: account_id_hex(token_b), + amount_a_raw: Some(String::from("1000000")), + amount_b_raw: Some(String::from("4000000")), + } + } + + fn read_failed() -> AccountRead { + AccountRead { + id: String::new(), + status: String::from("read_failed"), + account: None, + } + } + + /// A valid AMM config account read so `derive_pair` succeeds in plan tests. + fn valid_config(amm: nssa_core::program::ProgramId) -> AccountRead { + let token_program = parse_program_id(&"01".repeat(32)).unwrap(); + let twap_program = parse_program_id(&"02".repeat(32)).unwrap(); + let account = Account { + program_owner: amm, + data: Data::from(&AmmConfig { + token_program_id: token_program, + twap_oracle_program_id: twap_program, + authority: AccountId::new([0x09; 32]), + }), + ..Account::default() + }; + account_read(compute_config_pda(amm), &account) + } + + #[test] + fn create_quote_prices_the_opening() { + let token_a = AccountId::new([0xAA; 32]); + let token_b = AccountId::new([0xBB; 32]); + let value = liquidity_quote(quote_request(token_a, token_b)).unwrap(); + + assert_eq!(value["amountARaw"], "1000000"); + assert_eq!(value["amountBRaw"], "4000000"); + assert_eq!(value["lockedLpRaw"], MINIMUM_LIQUIDITY.to_string()); + // initial_lp = isqrt(1_000_000 * 4_000_000) = 2_000_000; creator LP = minus lock. + let initial_lp = isqrt_product(1_000_000, 4_000_000); + assert_eq!( + value["expectedLpRaw"], + (initial_lp - MINIMUM_LIQUIDITY).to_string() + ); + assert_eq!( + value["initialPriceRaw"], + spot_price_q64_64(1_000_000, 4_000_000).to_string() + ); + // Lean preview — no commitment / status / submittability fields. + assert!(value.get("quoteHash").is_none()); + assert!(value.get("canSubmit").is_none()); + assert!(value.get("poolStatus").is_none()); + } + + #[test] + fn create_quote_lp_is_orientation_independent() { + let token_a = AccountId::new([0xAA; 32]); + let token_b = AccountId::new([0xBB; 32]); + let ab = liquidity_quote(quote_request(token_a, token_b)).unwrap(); + // Swap display order and the paired amounts: the LP figure is symmetric. + let mut ba = quote_request(token_b, token_a); + ba.amount_a_raw = Some(String::from("4000000")); + ba.amount_b_raw = Some(String::from("1000000")); + let ba = liquidity_quote(ba).unwrap(); + assert_eq!(ab["expectedLpRaw"], ba["expectedLpRaw"]); + } + + #[test] + fn create_quote_rejects_same_token_and_tiny_amounts() { + let token = AccountId::new([0xAA; 32]); + assert_eq!( + liquidity_quote(quote_request(token, token)), + Err(String::from("same_token_pair")) + ); + + // isqrt(1 * 1) = 1 ≤ MINIMUM_LIQUIDITY ⇒ the pool can't open. + let token_b = AccountId::new([0xBB; 32]); + let mut tiny = quote_request(token, token_b); + tiny.amount_a_raw = Some(String::from("1")); + tiny.amount_b_raw = Some(String::from("1")); + assert_eq!(liquidity_quote(tiny), Err(String::from("amount_too_low"))); + } + + #[test] + fn create_plan_pairs_each_amount_with_its_canonical_vault() { + let program = "00".repeat(32); + let amm = parse_program_id(&program).unwrap(); + + // Display order is NON-canonical (token_a < token_b), so canonical `a` is the + // display `b`. This is the case where a misapplied swap would corrupt balances. + let token_a = AccountId::new([0x11; 32]); + let token_b = AccountId::new([0x22; 32]); + assert!(!is_canonical_pair(token_a, token_b)); + let (canonical_a, canonical_b) = (token_b, token_a); + + let holding_a = AccountId::new([0x0A; 32]); // holds display token_a + let holding_b = AccountId::new([0x0B; 32]); // holds display token_b (canonical a) + let lp = AccountId::new([0x0C; 32]); + + let value = create_pool_plan(CreatePoolPlanRequest { + amm_program_id: program.clone(), + config: valid_config(amm), + token_a_id: account_id_hex(token_a), + token_b_id: account_id_hex(token_b), + amount_a_raw: Some(String::from("1000000")), // deposit for display token_a + amount_b_raw: Some(String::from("4000000")), // deposit for display token_b + fee_bps: 30, + deadline_ms: String::from("1000"), + user_holding_a_id: account_id_hex(holding_a), + user_holding_b_id: account_id_hex(holding_b), + user_holding_lp_id: account_id_hex(lp), + }) + .unwrap(); + + let ids: Vec<&str> = value["accountIds"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect(); + let signers: Vec = value["signingRequirements"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_bool().unwrap()) + .collect(); + + let pool = compute_pool_pda(amm, canonical_a, canonical_b); + assert_eq!(ids[0], account_id_hex(compute_config_pda(amm))); + assert_eq!(ids[1], account_id_hex(pool)); + // Canonical vaults, in canonical order. + assert_eq!( + ids[2], + account_id_hex(compute_vault_pda(amm, pool, canonical_a)) + ); + assert_eq!( + ids[3], + account_id_hex(compute_vault_pda(amm, pool, canonical_b)) + ); + // user_holding_a is the CANONICAL token-a holding = display token_b's holding. + assert_eq!(ids[6], account_id_hex(holding_b)); + assert_eq!(ids[7], account_id_hex(holding_a)); + assert_eq!(ids[8], account_id_hex(lp)); + assert_eq!( + signers, + vec![false, false, false, false, false, false, true, true, true, false, false] + ); + + // The decisive check: the encoded instruction must carry token_a_amount = + // 4_000_000 (the deposit into canonical vault_a) — the amount the user entered + // for THAT token (display token_b), not display token_a's 1_000_000. Comparing + // against the re-encoded expected instruction proves balances follow their + // tokens through the swap. + let expected = risc0_zkvm::serde::to_vec(&amm_core::Instruction::NewDefinition { + token_a_amount: 4_000_000, + token_b_amount: 1_000_000, + fees: 30, + deadline: 1_000, + }) + .unwrap(); + let expected_words: Vec = expected.iter().map(|word| u64::from(*word)).collect(); + assert_eq!(value["instruction"], serde_json::json!(expected_words)); + } + + #[test] + fn create_plan_rejects_same_token() { + let token = AccountId::new([0xAA; 32]); + let value = create_pool_plan(CreatePoolPlanRequest { + amm_program_id: "00".repeat(32), + config: read_failed(), + token_a_id: account_id_hex(token), + token_b_id: account_id_hex(token), + amount_a_raw: Some(String::from("1")), + amount_b_raw: Some(String::from("1")), + fee_bps: 30, + deadline_ms: String::from("1"), + user_holding_a_id: account_id_hex(token), + user_holding_b_id: account_id_hex(token), + user_holding_lp_id: account_id_hex(token), + }); + assert_eq!(value, Err(String::from("same_token_pair"))); + } +} diff --git a/modules/amm/ffi/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs index f23a0cb..7ed1e89 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -7,6 +7,7 @@ mod config; mod context; mod funding; mod holding; +mod liquidity; mod pair; mod plan; mod position; @@ -21,10 +22,10 @@ mod tests; use std::{error::Error, fmt}; pub use request::{ - ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PoolIdRequest, - PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, - SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, - TokenIdsRequest, + ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, + PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, + ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, + SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, }; use serde_json::Value; @@ -129,6 +130,16 @@ pub fn swap_exact_out_plan(request: SwapExactOutPlanRequest) -> AmmResult { swap::swap_exact_out_plan(request).map_err(Into::into) } +/// Prices a create-pool deposit: the LP the creator receives and the opening price. +pub fn liquidity_quote(request: LiquidityQuoteRequest) -> AmmResult { + liquidity::liquidity_quote(request).map_err(Into::into) +} + +/// Builds the `NewDefinition` submission for creating a pool. +pub fn create_pool_plan(request: CreatePoolPlanRequest) -> AmmResult { + liquidity::create_pool_plan(request).map_err(Into::into) +} + /// Derives the AMM `ProgramId` (Image ID) from a deployed program binary. pub fn program_id(request: ProgramIdRequest) -> AmmResult { swap::program_id(request).map_err(Into::into) diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index 600640d..104a4d6 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -135,6 +135,39 @@ pub struct SwapExactOutPlanRequest { pub pool_data: String, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LiquidityQuoteRequest { + pub token_a_id: String, + pub token_b_id: String, + #[serde(default)] + pub amount_a_raw: Option, + #[serde(default)] + pub amount_b_raw: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreatePoolPlanRequest { + /// Resolved by the module from `AMM_PROGRAM_BIN` (like every id-deriving op) — + /// the FFI is stateless and can't read it itself. + 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 plan requests). + pub config: AccountRead, + pub token_a_id: String, + pub token_b_id: String, + #[serde(default)] + pub amount_a_raw: Option, + #[serde(default)] + pub amount_b_raw: Option, + pub fee_bps: u32, + pub deadline_ms: String, + pub user_holding_a_id: String, + pub user_holding_b_id: String, + pub user_holding_lp_id: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProgramIdRequest { diff --git a/modules/amm/ffi/src/ffi.rs b/modules/amm/ffi/src/ffi.rs index 6f05626..140c835 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -6,10 +6,10 @@ use std::{ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ - self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, - PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, - SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, - TokenIdsRequest, + self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, + LiquidityQuoteRequest, PairIdsRequest, PlanRequest, PoolIdRequest, ProgramIdRequest, + QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, + SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, }; #[derive(Serialize)] @@ -143,6 +143,16 @@ pub extern "C" fn amm_swap_exact_out_plan(request_json: *const c_char) -> *mut c call::(request_json, api::swap_exact_out_plan) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_liquidity_quote(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::liquidity_quote) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_create_pool_plan(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::create_pool_plan) +} + #[unsafe(no_mangle)] pub extern "C" fn amm_program_id(request_json: *const c_char) -> *mut c_char { call::(request_json, api::program_id) diff --git a/modules/amm/ffi/src/lib.rs b/modules/amm/ffi/src/lib.rs index 15fc708..e86c42c 100644 --- a/modules/amm/ffi/src/lib.rs +++ b/modules/amm/ffi/src/lib.rs @@ -6,11 +6,11 @@ mod ffi; pub mod api; pub use api::{ - config_id, context, pair_ids, plan, pool_id, program_id, quote, resolve_pool, - swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan, swap_exact_out_quote, swap_pair, - token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult, ConfigIdRequest, ContextRequest, - PairIdsRequest, PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, - QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, - SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, - WalletAccount, + config_id, context, create_pool_plan, liquidity_quote, pair_ids, plan, pool_id, program_id, + quote, resolve_pool, swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan, + swap_exact_out_quote, swap_pair, token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult, + ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, + PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, + ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, + SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, WalletAccount, }; diff --git a/modules/amm/ffi/tests/public_api.rs b/modules/amm/ffi/tests/public_api.rs index 166514c..90b1a58 100644 --- a/modules/amm/ffi/tests/public_api.rs +++ b/modules/amm/ffi/tests/public_api.rs @@ -1,4 +1,7 @@ -use amm_ffi::{config_id, ConfigIdRequest}; +use amm_ffi::{ + config_id, create_pool_plan, liquidity_quote, AmmResult, ConfigIdRequest, + CreatePoolPlanRequest, LiquidityQuoteRequest, +}; #[test] fn direct_rust_api_does_not_require_ffi() { @@ -10,3 +13,20 @@ fn direct_rust_api_does_not_require_ffi() { assert_eq!(response["status"], "ok"); assert!(response["configId"].is_string()); } + +// The create-pool surface must be reachable from the crate root too — Rust callers import from +// `amm_ffi::`, not `amm_ffi::api`. liquidity_quote is a pure preview, so exercise it directly; +// create_pool_plan needs chain reads, so a typed reference is enough to pin the re-export. +#[test] +fn create_pool_surface_is_reexported_from_crate_root() { + let quote = liquidity_quote(LiquidityQuoteRequest { + token_a_id: "11".repeat(32), + token_b_id: "22".repeat(32), + amount_a_raw: Some("1000000".into()), + amount_b_raw: Some("4000000".into()), + }) + .expect("a valid pure create-pool quote should succeed"); + assert_eq!(quote["amountARaw"], "1000000"); + + let _plan: fn(CreatePoolPlanRequest) -> AmmResult = create_pool_plan; +} diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index d8e723e..d18a646 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -746,6 +746,136 @@ std::string AmmModuleImpl::swapExactOutput(const std::string& def_a_hex, return jStr(obj, "tx_hash"); } +LogosMap AmmModuleImpl::liquidityQuote(const LogosMap& request) { + auto error = [](const std::string& err) { + return LogosMap{{"status", "error"}, {"error", err}}; + }; + + // Pure preview — no program id / chain reads / fee. Normalize the pair to hex (the + // liquidity UI still sources base58 ids from newPositionContext; 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"); + + // amountARaw/amountBRaw arrive as a JSON number (CLI) or decimal string (UI); + // coerce to canonical decimal strings (rejects floats — see jsonAmountToDecimal). + // If an amount field is present but malformed, return bad_amount; otherwise leave it + // out so the FFI returns amount_required. + json quoteRequest = { + {"tokenAId", token_a}, + {"tokenBId", token_b}, + }; + if (request.contains("amountARaw")) { + std::string amount_a_decimal; + if (!jsonAmountToDecimal(request.at("amountARaw"), amount_a_decimal)) + return error("bad_amount"); + quoteRequest["amountARaw"] = amount_a_decimal; + } + if (request.contains("amountBRaw")) { + std::string amount_b_decimal; + if (!jsonAmountToDecimal(request.at("amountBRaw"), amount_b_decimal)) + return error("bad_amount"); + quoteRequest["amountBRaw"] = amount_b_decimal; + } + + const FfiResult quoteResult = call(amm_liquidity_quote, quoteRequest); + if (!quoteResult.ok) + return error(quoteResult.error.empty() ? "backend_error" : quoteResult.error); + + // Success: wrap { amountARaw, amountBRaw, expectedLpRaw, lockedLpRaw, + // initialPriceRaw } in the standard envelope. + LogosMap out = quoteResult.value; + out["status"] = "ok"; + out["error"] = ""; + return out; +} + +LogosMap AmmModuleImpl::createPool(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 swapExactInQuote). + const std::string amm_program_id = ammProgramId(); + if (amm_program_id.empty()) + return error("config_missing"); + + // amm_create_pool_plan needs the config account for the twap program id the + // current-tick PDA derives from; a bad/absent config surfaces from the plan as + // config_unavailable (no bespoke check here — same as the swap plans). + 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 caller-provided LP holding) to hex + // (base58 tolerated — transitional). A new pool has no pre-existing LP holding, so + // lpHoldingId is a fresh account the caller supplies; an empty/invalid id fails here. + 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 amount_a_decimal; + std::string amount_b_decimal; + std::string deadline_decimal; + if (!jsonAmountToDecimal(request.value("amountARaw", json()), amount_a_decimal) + || !jsonAmountToDecimal(request.value("amountBRaw", json()), amount_b_decimal) + || !jsonAmountToDecimal(request.value("deadlineMs", json()), deadline_decimal)) + return error("bad_amount"); + + // feeBps deserializes into a u32 in the plan request, so a missing / null / float / string + // value would fail the FFI's serde parse and leak an "invalid request JSON" error instead of + // a stable code. Require a JSON integer here; fee-tier support is validated in the plan op. + const json fee_val = request.value("feeBps", json()); + if (!fee_val.is_number_integer()) + return error("bad_fee_bps_amount"); + + // amm_create_pool_plan resolves the pool accounts (canonicalizing the pair), + // encodes NewDefinition (with the fee), and returns a ready-to-submit plan. + const FfiResult planResult = call(amm_create_pool_plan, json{ + {"ammProgramId", amm_program_id}, + {"config", config}, + {"tokenAId", token_a}, + {"tokenBId", token_b}, + {"amountARaw", amount_a_decimal}, + {"amountBRaw", amount_b_decimal}, + {"feeBps", fee_val}, + {"deadlineMs", deadline_decimal}, + {"userHoldingAId", holding_a}, + {"userHoldingBId", holding_b}, + {"userHoldingLpId", user_lp}, + }); + 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("createPool: 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("createPool: 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"); + + // The native tx hash (64-char hex) is returned as-is — hex everywhere. + 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 dd59feb..2ef461d 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -101,6 +101,39 @@ public: const nlohmann::json& max_in, const nlohmann::json& deadline); + /// Prices creating a pool for (tokenAId, tokenBId) from the two deposit amounts. + /// A pure preview — no chain reads, and no fee needed (the fee is not part of the + /// pool PDA and doesn't affect the opening LP/price). Returns `{ status:"ok", + /// error:"", amountARaw, amountBRaw, expectedLpRaw, lockedLpRaw, initialPriceRaw }` + /// computed via the shared `amm_core` opening-LP math, so `expectedLpRaw` is + /// exactly what the guest mints. `request` carries `{ tokenAId, tokenBId, + /// amountARaw, amountBRaw }` (ids hex or base58, normalized to hex; amounts a JSON + /// integer or decimal string). On failure: `{ status:"error", error: }` — + /// `invalid_token_id`, `same_token_pair`, `bad_amount` (an amount field is present + /// but not a valid integer — e.g. a float, from `jsonAmountToDecimal`), + /// `amount_required` (an amount field is omitted), `invalid_raw_amount` (non-digit or + /// beyond the u128 range), `amount_must_be_positive` (zero), `amount_too_low` + /// (deposits below the locked minimum), or `backend_error`. `amount_required`, + /// `invalid_raw_amount`, `amount_must_be_positive`, `same_token_pair`, and + /// `amount_too_low` come from the FFI; the rest from the module. The caller decides + /// create-vs-add by pool existence before calling this. + LogosMap liquidityQuote(const LogosMap& request); + + /// Submits a `NewDefinition` transaction creating the pool for the request's pair. + /// `request` carries `{ tokenAId, tokenBId, holdingAId, holdingBId, lpHoldingId, + /// amountARaw, amountBRaw, feeBps, deadlineMs }` (ids hex or base58, normalized to + /// hex; amounts/deadline a JSON integer or decimal string, deadline a u64 unix-ms). + /// The caller provides `lpHoldingId` — a fresh (empty) account the guest initializes + /// and mints the creator's LP tokens into; a new pool has no pre-existing LP holding, + /// and the module never creates wallet accounts. On success: + /// `{ status:"ok", error:"", transactionId: }`. On failure: + /// `{ status:"error", error: }` — `config_missing`, `backend_error`, + /// `invalid_account_id`, `bad_amount` (malformed amount/deadline), `bad_fee_bps_amount` + /// (`feeBps` not a JSON integer), `wallet_submission_failed`, or a plan code (e.g. + /// `invalid_fee_tier`, `config_unavailable`). Unlike the swaps, a submit failure carries + /// a code so the create-pool UI can tell the user why. + LogosMap createPool(const LogosMap& request); + /// Reads the token list config at TOKENS_CONFIG (a JSON array of /// { symbol, name, definitionId, holding, decimals }) and returns it, /// normalizing definitionId/holding to lowercase hex. Empty list if