feat(modules/amm): add createPool quote + plan ops and module methods

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.
This commit is contained in:
r4bbit
2026-08-11 12:04:47 +02:00
parent 4363f13912
commit 526d50bff1
9 changed files with 616 additions and 16 deletions
+4
View File
@@ -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);
/**
+359
View File
@@ -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<u128, String> {
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::<u128>().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<u64, String> {
value
.parse::<u64>()
.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<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 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<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 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::<Vec<_>>(),
"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<bool> = 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<u64> = 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")));
}
}
+15 -4
View File
@@ -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)
+33
View File
@@ -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<String>,
#[serde(default)]
pub amount_b_raw: Option<String>,
}
#[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<String>,
#[serde(default)]
pub amount_b_raw: Option<String>,
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 {
+14 -4
View File
@@ -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::<SwapExactOutPlanRequest>(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::<LiquidityQuoteRequest>(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::<CreatePoolPlanRequest>(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::<ProgramIdRequest>(request_json, api::program_id)
+7 -7
View File
@@ -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,
};
+21 -1
View File
@@ -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;
}
+130
View File
@@ -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<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("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();
+33
View File
@@ -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:<code> }` —
/// `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:<hex tx hash> }`. On failure:
/// `{ status:"error", error:<code> }` — `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