feat(modules/amm): add remove-liquidity module ops

The remove-liquidity counterpart of the add ops, following the same lean
pattern: pure Rust FFI pricing/plan + thin C++ orchestration, hex ids end to
end, the token pair oriented to the pool's stored order server-side. No UI yet.

FFI (modules/amm/ffi):
- remove_liquidity_quote: burning lpAmountRaw returns the proportional share of
  each reserve — withdraw = floor(reserve·lp/supply), the guest's own math —
  plus the slippage-floored minimumAmount{A,B}Raw the submit enforces and the
  pool's spot price, all in the caller's display order. Guards: same_token_pair,
  invalid_slippage, no_pool, insufficient_pool_liquidity (burn exceeds the
  supply unlocked above MINIMUM_LIQUIDITY), pair_mismatch, amount_too_low,
  minimum_amount_zero.
- remove_liquidity_plan: encodes RemoveLiquidity over the fixed 10-account IDL
  order, orienting (min_amount, holding) to the pool's stored order like the add
  plan — but only user_holding_lp signs (it is burned) and there is no fresh
  holding: the existing token a/b holdings receive the withdrawal.
- Wired through mod.rs / ffi.rs (cbindgen header regenerated). Unit tests cover
  the guest-formula pricing + display orientation, the guard set, the plan's
  account/signer layout, and fail-closed. amm_ffi: 41 tests pass, clippy clean.

C++ module (modules/amm/src):
- removeLiquidityQuote / removeLiquidity mirror addLiquidityQuote / addLiquidity:
  read the pool server-side, call the ops, submit. removeLiquidity takes no fresh
  account (the LP holding already exists) and threads the caller-provided
  deadlineMs like the other submits. Public methods → auto-exposed via the
  universal-module dispatch.
This commit is contained in:
r4bbit
2026-08-11 17:51:51 +02:00
parent 64e7614e74
commit 44b70e4333
7 changed files with 721 additions and 5 deletions
+4
View File
@@ -44,6 +44,10 @@ char *amm_add_liquidity_quote(const char *request_json);
char *amm_add_liquidity_plan(const char *request_json);
char *amm_remove_liquidity_quote(const char *request_json);
char *amm_remove_liquidity_plan(const char *request_json);
char *amm_token_holdings(const char *request_json);
char *amm_program_id(const char *request_json);
+478 -1
View File
@@ -22,7 +22,7 @@ use super::{
pair::{derive_pair, is_canonical_pair},
quote::minimum_opening_pair,
AddLiquidityPlanRequest, AddLiquidityQuoteRequest, CreatePoolPlanRequest,
LiquidityQuoteRequest,
LiquidityQuoteRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest,
};
use crate::account::{account_id_from_hex, account_id_hex, parse_program_id};
@@ -397,6 +397,183 @@ pub(super) fn add_liquidity_plan(request: AddLiquidityPlanRequest) -> Result<Val
))
}
/// Prices removing liquidity: burning `lp_amount_raw` of the pool returns the proportional
/// share of each reserve — `withdraw = floor(reserve · lp / supply)`, the same math the guest
/// (`amm_program::remove::remove_liquidity`) runs. `slippage_bps` sets the `minimumAmount*Raw`
/// floors the submit passes as the guest's nonzero `min_amount_to_remove_token_*`. Amounts are
/// returned in the caller's (display) token order. Errors: `same_token_pair`, `invalid_slippage`,
/// `no_pool`, `insufficient_pool_liquidity` (the burn exceeds the supply unlocked above the
/// permanently-locked `MINIMUM_LIQUIDITY`), `pair_mismatch`, `amount_too_low` (a withdrawal
/// rounds to zero), `minimum_amount_zero` (slippage rounds a floor to zero), plus the shared
/// amount-parse codes.
pub(super) fn remove_liquidity_quote(
request: RemoveLiquidityQuoteRequest,
) -> 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 lp_amount = positive_amount(Some(&request.lp_amount_raw))?;
if u128::from(request.slippage_bps) >= FEE_BPS_DENOMINATOR {
return Err(String::from("invalid_slippage"));
}
// Decode the pool; absent / undecodable / zero-supply ⇒ nothing to remove from.
let pool = hex::decode(&request.pool_data)
.ok()
.and_then(|bytes| borsh::from_slice::<PoolDefinition>(&bytes).ok())
.filter(|pool| pool.liquidity_pool_supply != 0)
.ok_or_else(|| String::from("no_pool"))?;
if pool.reserve_a == 0 || pool.reserve_b == 0 {
return Err(String::from("no_pool"));
}
// The pool permanently locks MINIMUM_LIQUIDITY at creation, so a burn can only draw on the
// supply beyond it — the guest asserts `remove_amount <= supply - MINIMUM_LIQUIDITY`.
let unlocked = pool
.liquidity_pool_supply
.checked_sub(MINIMUM_LIQUIDITY)
.filter(|unlocked| *unlocked > 0)
.ok_or_else(|| String::from("no_pool"))?;
if lp_amount > unlocked {
return Err(String::from("insufficient_pool_liquidity"));
}
// Orient the caller's (display) tokens to the pool's canonical (a, b) order so the reserve
// math lines up with the guest; `reversed` flips the results back to display order.
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"));
};
// Guest math: floor(reserve · lp / supply) per side.
let withdraw_a = mul_div_floor(pool.reserve_a, lp_amount, pool.liquidity_pool_supply);
let withdraw_b = mul_div_floor(pool.reserve_b, lp_amount, pool.liquidity_pool_supply);
if withdraw_a == 0 || withdraw_b == 0 {
return Err(String::from("amount_too_low"));
}
// Slippage floors — the guest requires both `min_amount_to_remove_token_*` nonzero.
let slippage_complement = FEE_BPS_DENOMINATOR - u128::from(request.slippage_bps);
let minimum_a = mul_div_floor(withdraw_a, slippage_complement, FEE_BPS_DENOMINATOR);
let minimum_b = mul_div_floor(withdraw_b, slippage_complement, FEE_BPS_DENOMINATOR);
if minimum_a == 0 || minimum_b == 0 {
return Err(String::from("minimum_amount_zero"));
}
// Back to display order for the response; the price uses the display-oriented reserves.
let (display_a, display_b, minimum_display_a, minimum_display_b) = if reversed {
(withdraw_b, withdraw_a, minimum_b, minimum_a)
} else {
(withdraw_a, withdraw_b, minimum_a, minimum_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!({
"amountARaw": display_a.to_string(),
"amountBRaw": display_b.to_string(),
"minimumAmountARaw": minimum_display_a.to_string(),
"minimumAmountBRaw": minimum_display_b.to_string(),
"priceRaw": price.to_string(),
}))
}
/// Builds the `RemoveLiquidity` submission for an existing pool. Like `add_liquidity_plan` it
/// orients the `(min_amount, holding)` pair to the pool's ALREADY-STORED order — vaults and the
/// LP definition come from `pool_data`, which the guest asserts against — but only
/// `user_holding_lp` signs (it is burned), and there is no fresh holding: the existing token
/// a/b holdings receive the withdrawal. `min_amount_*_raw` are the caller's slippage floors and
/// must be positive (the guest rejects a zero). Emits the fixed 10-account IDL order.
/// Recoverable failures fail closed as `Err` (`same_token_pair`, `config_unavailable`,
/// `no_pool`, `pair_mismatch`, bad amounts).
pub(super) fn remove_liquidity_plan(request: RemoveLiquidityPlanRequest) -> 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 lp_amount = positive_amount(Some(&request.lp_amount_raw))?;
let min_a = positive_amount(Some(&request.min_amount_a_raw))?;
let min_b = positive_amount(Some(&request.min_amount_b_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 vaults are canonical and unused here — the guest
// asserts the vaults against the pool's stored ids, taken below.)
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Err(String::from("config_unavailable"));
};
// Vaults + LP definition come from the pool's stored ids (the guest asserts against them).
let Some(pool) = hex::decode(&request.pool_data)
.ok()
.and_then(|bytes| borsh::from_slice::<PoolDefinition>(&bytes).ok())
else {
return Err(String::from("no_pool"));
};
// Orient (min amount, holding) to the pool's STORED order — NOT is_canonical_pair. The guest
// transfers vault_a (== pool.vault_a_id, which holds definition_token_a_id) into
// user_holding_a, so user_a / min_pool_a must be that token's holding / floor. A pool created
// outside the FFI can store a non-canonical order, so keying off is_canonical_pair would
// route a withdrawal into the wrong holding.
let (min_pool_a, min_pool_b, user_a, user_b) =
if token_a == pool.definition_token_a_id && token_b == pool.definition_token_b_id {
(min_a, min_b, holding_a, holding_b)
} else if token_a == pool.definition_token_b_id && token_b == pool.definition_token_a_id {
(min_b, min_a, holding_b, holding_a)
} else {
return Err(String::from("pair_mismatch"));
};
let instruction = risc0_zkvm::serde::to_vec(&amm_core::Instruction::RemoveLiquidity {
remove_liquidity_amount: lp_amount,
min_amount_to_remove_token_a: min_pool_a,
min_amount_to_remove_token_b: min_pool_b,
deadline,
})
.map_err(|error| format!("instruction serialization failed: {error}"))?;
// Fixed IDL account order for RemoveLiquidity; only user_holding_lp (burned) signs.
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, false, false, 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};
@@ -905,4 +1082,304 @@ mod tests {
no_pool.config = valid_config(amm);
assert_eq!(add_liquidity_plan(no_pool), Err(String::from("no_pool")));
}
#[test]
fn remove_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. Burning 10% of supply returns 10% of each reserve.
let ab = remove_liquidity_quote(RemoveLiquidityQuoteRequest {
token_a_id: account_id_hex(def_a),
token_b_id: account_id_hex(def_b),
lp_amount_raw: String::from("100000"),
slippage_bps: 50,
pool_data: pool_hex(&pool),
})
.unwrap();
assert_eq!(ab["amountARaw"], "100000"); // floor(1_000_000 * 100_000 / 1_000_000)
assert_eq!(ab["amountBRaw"], "200000"); // floor(2_000_000 * 100_000 / 1_000_000)
// minimum = floor(withdraw * (10000 - 50) / 10000) — the slippage floor per side.
assert_eq!(ab["minimumAmountARaw"], "99500");
assert_eq!(ab["minimumAmountBRaw"], "199000");
assert_eq!(
ab["priceRaw"],
spot_price_q64_64(1_000_000, 2_000_000).to_string()
);
// Reverse display order: withdrawals, minimums, and the price all flip to display order.
let ba = remove_liquidity_quote(RemoveLiquidityQuoteRequest {
token_a_id: account_id_hex(def_b),
token_b_id: account_id_hex(def_a),
lp_amount_raw: String::from("100000"),
slippage_bps: 50,
pool_data: pool_hex(&pool),
})
.unwrap();
assert_eq!(ba["amountARaw"], "200000"); // display token def_b side
assert_eq!(ba["amountBRaw"], "100000"); // display token def_a side
assert_eq!(ba["minimumAmountARaw"], "199000");
assert_eq!(ba["minimumAmountBRaw"], "99500");
assert_eq!(
ba["priceRaw"],
spot_price_q64_64(2_000_000, 1_000_000).to_string()
);
}
#[test]
fn remove_quote_rejects_no_pool_mismatch_and_bounds() {
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, lp: &str, data: String| {
RemoveLiquidityQuoteRequest {
token_a_id: account_id_hex(token_a),
token_b_id: account_id_hex(token_b),
lp_amount_raw: lp.into(),
slippage_bps: 50,
pool_data: data,
}
};
// Same token pair.
assert_eq!(
remove_liquidity_quote(req(def_a, def_a, "1", pool_hex(&pool))),
Err(String::from("same_token_pair"))
);
// Empty / undecodable pool data, and a zero-supply pool.
assert_eq!(
remove_liquidity_quote(req(def_a, def_b, "1", String::new())),
Err(String::from("no_pool"))
);
let empty = PoolDefinition {
definition_token_a_id: def_a,
definition_token_b_id: def_b,
liquidity_pool_supply: 0,
..Default::default()
};
assert_eq!(
remove_liquidity_quote(req(def_a, def_b, "1", pool_hex(&empty))),
Err(String::from("no_pool"))
);
// Burning more than the supply unlocked above MINIMUM_LIQUIDITY (1_000_000 - 1000).
assert_eq!(
remove_liquidity_quote(req(def_a, def_b, "999001", pool_hex(&pool))),
Err(String::from("insufficient_pool_liquidity"))
);
// A decoded pool that isn't for this pair.
let other = AccountId::new([0xCC; 32]);
assert_eq!(
remove_liquidity_quote(req(def_a, other, "1", pool_hex(&pool))),
Err(String::from("pair_mismatch"))
);
// Slippage at/above 100%.
assert_eq!(
remove_liquidity_quote(RemoveLiquidityQuoteRequest {
token_a_id: account_id_hex(def_a),
token_b_id: account_id_hex(def_b),
lp_amount_raw: String::from("100000"),
slippage_bps: 10_000,
pool_data: pool_hex(&pool),
}),
Err(String::from("invalid_slippage"))
);
// A lopsided pool where one side's withdrawal floors to zero.
let lopsided = 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: 1,
fees: 30,
..Default::default()
};
assert_eq!(
remove_liquidity_quote(req(def_a, def_b, "1", pool_hex(&lopsided))),
Err(String::from("amount_too_low"))
);
// Withdrawals of 1 and 2: 50 bps slippage floors the token-A minimum to 0, which the
// guest's nonzero `min_amount_to_remove_token_a` rejects.
assert_eq!(
remove_liquidity_quote(req(def_a, def_b, "1", pool_hex(&pool))),
Err(String::from("minimum_amount_zero"))
);
}
#[test]
fn remove_plan_orients_holdings_to_the_pools_stored_order() {
let program = "00".repeat(32);
let amm = parse_program_id(&program).unwrap();
// Same non-canonical pool as the add-plan test: definition_token_a_id = token_a even
// though is_canonical_pair's canonical-a is token_b. The plan must follow the POOL's
// stored order so each withdrawal lands in the right holding.
let token_a = AccountId::new([0x11; 32]);
let token_b = AccountId::new([0x22; 32]);
assert!(!is_canonical_pair(token_a, token_b));
let vault_a = AccountId::new([0xA1; 32]);
let vault_b = AccountId::new([0xB1; 32]);
let lp_def = AccountId::new([0xCC; 32]);
let pool = PoolDefinition {
definition_token_a_id: token_a,
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 (receives)
let holding_b = AccountId::new([0x0B; 32]); // token_b holding (receives)
let lp = AccountId::new([0x0C; 32]); // burned (signs)
let run = |ta: String,
tb: String,
min_a: &str,
min_b: &str,
ha: String,
hb: String|
-> (Vec<String>, serde_json::Value, serde_json::Value) {
let value = remove_liquidity_plan(RemoveLiquidityPlanRequest {
amm_program_id: program.clone(),
config: valid_config(amm),
token_a_id: ta,
token_b_id: tb,
lp_amount_raw: String::from("100000"),
min_amount_a_raw: min_a.to_string(),
min_amount_b_raw: min_b.to_string(),
deadline_ms: String::from("1000"),
user_holding_a_id: ha,
user_holding_b_id: hb,
user_holding_lp_id: account_id_hex(lp),
pool_data: pool_hex(&pool),
})
.unwrap();
let ids = value["accountIds"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<String>>();
(
ids,
value["instruction"].clone(),
value["signingRequirements"].clone(),
)
};
// The instruction the guest must receive: token_a's floor with vault_a's token,
// token_b's floor with vault_b's — regardless of the caller's argument order.
let expected_instruction = {
let words = risc0_zkvm::serde::to_vec(&amm_core::Instruction::RemoveLiquidity {
remove_liquidity_amount: 100_000,
min_amount_to_remove_token_a: 90_000, // token_a's floor
min_amount_to_remove_token_b: 180_000, // token_b's floor
deadline: 1_000,
})
.unwrap();
serde_json::json!(words.iter().map(|w| u64::from(*w)).collect::<Vec<u64>>())
};
// Only user_holding_lp (index 7) signs — a/b just receive.
let expected_signers = serde_json::json!([
false, false, false, false, false, false, false, true, false, false,
]);
let assert_aligned =
|ids: &[String], instruction: &serde_json::Value, signers: &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));
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);
assert_eq!(signers, &expected_signers);
};
// Caller order == the pool's stored order → NO swap.
let (ids, instruction, signers) = run(
account_id_hex(token_a),
account_id_hex(token_b),
"90000",
"180000",
account_id_hex(holding_a),
account_id_hex(holding_b),
);
assert_aligned(&ids, &instruction, &signers);
// Caller order reversed vs the pool → SWAP, so user_a stays token_a's holding and each
// floor follows its token.
let (ids, instruction, signers) = run(
account_id_hex(token_b),
account_id_hex(token_a),
"180000",
"90000",
account_id_hex(holding_b),
account_id_hex(holding_a),
);
assert_aligned(&ids, &instruction, &signers);
}
#[test]
fn remove_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| {
RemoveLiquidityPlanRequest {
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),
lp_amount_raw: String::from("1"),
min_amount_a_raw: String::from("1"),
min_amount_b_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!(
remove_liquidity_plan(base(token, token, String::new())),
Err(String::from("same_token_pair"))
);
// Unavailable config (read_failed) surfaces before the pool decode.
assert_eq!(
remove_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!(remove_liquidity_plan(no_pool), Err(String::from("no_pool")));
}
}
+10 -1
View File
@@ -19,7 +19,8 @@ use std::{error::Error, fmt};
pub use request::{
AddLiquidityPlanRequest, AddLiquidityQuoteRequest, ConfigIdRequest, ContextRequest,
CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest,
ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest,
SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest,
};
use serde_json::Value;
@@ -133,6 +134,14 @@ pub fn add_liquidity_plan(request: AddLiquidityPlanRequest) -> AmmResult {
liquidity::add_liquidity_plan(request).map_err(Into::into)
}
pub fn remove_liquidity_quote(request: RemoveLiquidityQuoteRequest) -> AmmResult {
liquidity::remove_liquidity_quote(request).map_err(Into::into)
}
pub fn remove_liquidity_plan(request: RemoveLiquidityPlanRequest) -> AmmResult {
liquidity::remove_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)
+40
View File
@@ -214,6 +214,46 @@ pub struct AddLiquidityPlanRequest {
pub pool_data: String,
}
/// Prices burning `lp_amount_raw` of an existing pool's LP. `slippage_bps` sets the
/// `minimumAmount*Raw` floors the submit enforces (the guest requires both nonzero and
/// `withdraw >= min`). `pool_data` is the hex Borsh `PoolDefinition` (empty ⇒ no pool).
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RemoveLiquidityQuoteRequest {
pub token_a_id: String,
pub token_b_id: String,
pub lp_amount_raw: String,
#[serde(default)]
pub slippage_bps: u32,
pub pool_data: String,
}
/// Builds the `RemoveLiquidity` submission — the remove counterpart of
/// `AddLiquidityPlanRequest`. `min_amount_*_raw` are the caller's slippage floors on the
/// tokens withdrawn (the guest's `min_amount_to_remove_token_*`, both applied at submit and
/// required nonzero); `pool_data` supplies the stored vault / LP-definition ids the guest
/// asserts against. Unlike add/create there is no fresh holding — the caller's existing LP
/// holding is burned and the existing token a/b holdings receive the withdrawal.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RemoveLiquidityPlanRequest {
/// 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 / add plan requests).
pub config: AccountRead,
pub token_a_id: String,
pub token_b_id: String,
pub lp_amount_raw: String,
pub min_amount_a_raw: String,
pub min_amount_b_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 {
+13 -3
View File
@@ -8,9 +8,9 @@ use serde::{de::DeserializeOwned, Serialize};
use crate::api::{
self, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, AmmApiError, AmmResult,
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest,
PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapExactInPlanRequest,
SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest,
TokenHoldingsRequest, TokenIdsRequest,
PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest,
ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest,
};
#[derive(Serialize)]
@@ -154,6 +154,16 @@ pub extern "C" fn amm_add_liquidity_plan(request_json: *const c_char) -> *mut c_
call::<AddLiquidityPlanRequest>(request_json, api::add_liquidity_plan)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_remove_liquidity_quote(request_json: *const c_char) -> *mut c_char {
call::<RemoveLiquidityQuoteRequest>(request_json, api::remove_liquidity_quote)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_remove_liquidity_plan(request_json: *const c_char) -> *mut c_char {
call::<RemoveLiquidityPlanRequest>(request_json, api::remove_liquidity_plan)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_token_holdings(request_json: *const c_char) -> *mut c_char {
call::<TokenHoldingsRequest>(request_json, api::token_holdings)
+151
View File
@@ -1022,6 +1022,157 @@ LogosMap AmmModuleImpl::addLiquidity(const LogosMap& request) {
return LogosMap{{"status", "ok"}, {"error", ""}, {"transactionId", jStr(obj, "tx_hash")}};
}
LogosMap AmmModuleImpl::removeLiquidityQuote(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 lp_amount_decimal;
if (!jsonAmountToDecimal(request.value("lpAmountRaw", json()), lp_amount_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");
// slippageBps derives the minimumAmount*Raw floors the submit enforces. Require an integer
// JSON number and reject everything else with a stable invalid_slippage (same as
// addLiquidityQuote): is_number() would also accept a float (and get<int64_t>() on a
// number_float THROWS, terminating the module), while a string / bool would fall through to a
// silent 0. A missing field defaults to 0 (no slippage). Negative or >= 100% is likewise
// invalid_slippage.
const json slippage_val = request.value("slippageBps", json(0));
if (!slippage_val.is_number_integer())
return error("invalid_slippage");
const int64_t slippage_bps = slippage_val.get<int64_t>();
if (slippage_bps < 0 || slippage_bps >= 10000)
return error("invalid_slippage");
const FfiResult quoteResult = call(amm_remove_liquidity_quote, json{
{"tokenAId", token_a},
{"tokenBId", token_b},
{"lpAmountRaw", lp_amount_decimal},
{"slippageBps", slippage_bps},
{"poolData", pool_data},
});
if (!quoteResult.ok)
return error(quoteResult.error.empty() ? "backend_error" : quoteResult.error);
// Success: wrap { amountARaw, amountBRaw, minimumAmountARaw, minimumAmountBRaw, priceRaw }.
LogosMap out = quoteResult.value;
out["status"] = "ok";
out["error"] = "";
return out;
}
LogosMap AmmModuleImpl::removeLiquidity(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 addLiquidity).
const std::string amm_program_id = ammProgramId();
if (amm_program_id.empty())
return error("config_missing");
// amm_remove_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. Unlike add/create there is no fresh account: the LP
// holding already exists (it is burned) and the token a/b holdings receive the withdrawal.
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 lp_amount_decimal;
std::string min_a_decimal;
std::string min_b_decimal;
std::string deadline_decimal;
if (!jsonAmountToDecimal(request.value("lpAmountRaw", json()), lp_amount_decimal)
|| !jsonAmountToDecimal(request.value("minAmountARaw", json()), min_a_decimal)
|| !jsonAmountToDecimal(request.value("minAmountBRaw", json()), min_b_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_remove_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_remove_liquidity_plan resolves the pool accounts, encodes RemoveLiquidity (with the
// per-side slippage floors + caller deadline), and returns a ready-to-submit plan.
const FfiResult planResult = call(amm_remove_liquidity_plan, json{
{"ammProgramId", amm_program_id},
{"config", config},
{"tokenAId", token_a},
{"tokenBId", token_b},
{"lpAmountRaw", lp_amount_decimal},
{"minAmountARaw", min_a_decimal},
{"minAmountBRaw", min_b_decimal},
{"deadlineMs", deadline_decimal},
{"userHoldingAId", holding_a},
{"userHoldingBId", holding_b},
{"userHoldingLpId", user_lp},
{"poolData", pool_data},
});
if (!planResult.ok)
return error(planResult.error.empty() ? "backend_error" : planResult.error);
const json plan = planResult.value;
const std::vector<std::string> accounts = jsonStrVec(plan.value("accountIds", json::array()));
const std::vector<bool> signers = jsonBoolVec(plan.value("signingRequirements", json::array()));
const std::vector<uint8_t> instruction = jsonWordsToLeBytes(plan.value("instruction", json::array()));
const std::string program_id = jStr(plan, "programId");
AMM_TRACE("removeLiquidity: 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("removeLiquidity: tx reply=" << reply);
const auto obj = json::parse(reply, nullptr, /*allow_exceptions=*/false);
if (!obj.is_object() || !obj.value("success", false))
return error("wallet_submission_failed");
return LogosMap{{"status", "ok"}, {"error", ""}, {"transactionId", jStr(obj, "tx_hash")}};
}
LogosList AmmModuleImpl::tokenList() {
LogosList out = LogosList::array();
+25
View File
@@ -159,6 +159,31 @@ public:
/// `config_unavailable`).
LogosMap addLiquidity(const LogosMap& request);
/// Prices a `RemoveLiquidity` from the existing pool for (tokenAId, tokenBId): burning
/// `lpAmountRaw` returns the proportional share of each reserve. Reads the pool
/// server-side (like the add quote) and runs the guest's `floor(reserve·lp/supply)` math.
/// Returns `{ status:"ok", error:"", amountARaw, amountBRaw, minimumAmountARaw,
/// minimumAmountBRaw, priceRaw }` — the withdrawals (display order), the slippage floors
/// the submit enforces, and the pool's spot price. `request` carries `{ tokenAId, tokenBId,
/// lpAmountRaw, slippageBps }` (ids hex or base58, normalized to hex; amount a JSON integer
/// or decimal string). On failure: `{ status:"error", error:<code> }` — `invalid_token_id`,
/// `config_missing`, `bad_amount`, `invalid_slippage`, `no_pool`, `pair_mismatch`,
/// `insufficient_pool_liquidity`, `amount_too_low`, `minimum_amount_zero`, or
/// `backend_error`.
LogosMap removeLiquidityQuote(const LogosMap& request);
/// Submits a `RemoveLiquidity` transaction against the request's pool. `request` carries
/// `{ tokenAId, tokenBId, holdingAId, holdingBId, lpHoldingId, lpAmountRaw, minAmountARaw,
/// minAmountBRaw, deadlineMs }` (ids hex or base58, normalized to hex; amounts/deadline a
/// JSON integer or decimal string). `lpHoldingId` is the existing holding burned; the token
/// a/b holdings receive the withdrawal (no fresh account, unlike add/create). `minAmount*Raw`
/// are the caller's slippage floors on the tokens withdrawn. On success:
/// `{ status:"ok", error:"", transactionId:<hex tx hash> }`. On failure:
/// `{ status:"error", error:<code> }` — `config_missing`, `backend_error`,
/// `invalid_account_id`, `bad_amount`, `wallet_submission_failed`, or a plan code (e.g.
/// `no_pool`, `config_unavailable`).
LogosMap removeLiquidity(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