Files
lez-programs/modules/amm/src/amm_module_impl.h
T
r4bbit b5e8239ee6 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.
2026-08-11 16:42:27 +02:00

270 lines
17 KiB
C++

#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include <logos_json.h> // LogosMap / LogosList (nlohmann::json aliases)
#include <logos_module_context.h> // LogosModuleContext base + modules()
// AMM business logic as a universal core Logos module.
//
// Orchestration only: the AMM domain math (PDA derivation, on-chain account
// decoding, quote/plan computation, and instruction encoding) lives in the Rust
// `amm_ffi` crate and is reached through its JSON FFI (amm_ffi.h — one
// `char* op(const char*)` per operation, request and response both JSON). This
// module sequences those ops with chain I/O delegated to the
// `logos_execution_zone` wallet module (reached via modules().logos_execution_zone).
//
// The same surface is consumed by the QML UI (via modules().amm_module) and
// headlessly (logoscore call amm_module ...). The swap / add-liquidity
// orchestration and the backend's network-context derivation are made Qt-free
// (std::string / LogosMap / LogosList / nlohmann::json) as the universal
// authoring model requires.
//
// Public methods ARE the module's API; the Qt plugin glue is generated from
// this header because metadata.json sets "interface": "universal". Keep the
// header Qt-free — std types only.
class AmmModuleImpl : public LogosModuleContext {
public:
AmmModuleImpl() = default;
~AmmModuleImpl() = default;
/// Derives the pool PDAs (config / pool / vaults / current-tick) for the
/// (def_a_hex, def_b_hex) pair and reads the pool's on-chain reserves.
/// On success: `{ exists:true, reserveA, reserveB, feeBps }` (reserveA/
/// reserveB in the pool's canonical def order). Otherwise
/// `{ exists:false, error:<code> }`: `no_program_bin` (AMM_PROGRAM_BIN
/// unset/unreadable/bad), `amm_not_initialized` (config undecodable),
/// `bad_config` (bad ids / internal decode failure), `same_token_pair`, or
/// `no_pool` for the ordinary "no pool / no liquidity yet" state.
LogosMap resolvePool(const std::string& def_a_hex, const std::string& def_b_hex);
/// Prices a `SwapExactInput` for the (token_in_hex, token_out_hex) pair:
/// reads the pool and returns `{ status:"ok", error:"", expectedOutRaw,
/// minReceivedRaw, priceImpactBps }`, oriented and computed server-side via
/// the shared on-chain formula. `amount_in` accepts a JSON integer or a
/// decimal string (JSON floats rejected); `slippage_bps` is basis points.
/// On failure: `{ status:"error", error:<code> }` — `no_pool` (no pool /
/// liquidity), `config_missing` (AMM_PROGRAM_BIN unset/unreadable),
/// `bad_amount`, `invalid_slippage` (`slippage_bps` out of range), or
/// `backend_error`. Pool metadata (reserves, fee) comes from `resolvePool`,
/// so it isn't echoed here.
LogosMap swapExactInQuote(const std::string& token_in_hex,
const std::string& token_out_hex,
const nlohmann::json& amount_in,
int64_t slippage_bps);
/// Prices a `SwapExactOutput` for the (token_in_hex, token_out_hex) pair:
/// reads the pool and returns `{ status:"ok", error:"", requiredInRaw,
/// maxInRaw, priceImpactBps }`, oriented and computed server-side via the
/// shared on-chain formula. `amount_out` accepts a JSON integer or a decimal
/// string (JSON floats rejected); `slippage_bps` is basis points. On failure:
/// `{ status:"error", error:<code> }` — `no_pool` (no pool / liquidity),
/// `output_exceeds_liquidity` (amount_out ≥ reserve), `config_missing`
/// (AMM_PROGRAM_BIN unset/unreadable), `bad_amount`, `invalid_slippage`
/// (`slippage_bps` out of range), or `backend_error`.
LogosMap swapExactOutQuote(const std::string& token_in_hex,
const std::string& token_out_hex,
const nlohmann::json& amount_out,
int64_t slippage_bps);
/// Submits an on-chain SwapExactInput transaction against the pool for
/// (def_a_hex = token in, def_b_hex = token out). amount_in / min_out are
/// u128 base-unit amounts; deadline is a u64 unix-ms timestamp. Each accepts
/// EITHER a small JSON integer (bare `1000` on the CLI) OR a decimal string
/// (what the UI passes, and what the CLI must use for any big value — large
/// amounts and the unix-ms deadline — as a quote-wrapped arg like
/// '"1000000000000000000"'). Declared `nlohmann::json` so the generated
/// dispatch hands us the raw value; JSON floats are rejected rather than
/// submit a silently-rounded amount. Returns the tx hash, or an empty string
/// on failure (no pool, unreadable AMM_PROGRAM_BIN, bad inputs, failed tx).
std::string swapExactInput(const std::string& def_a_hex,
const std::string& def_b_hex,
const std::string& user_input_holding_hex,
const std::string& user_output_holding_hex,
const nlohmann::json& amount_in,
const nlohmann::json& min_out,
const nlohmann::json& deadline);
/// Submits an on-chain SwapExactOutput transaction against the pool for
/// (def_a_hex = token in, def_b_hex = token out). amount_out / max_in are
/// u128 base-unit amounts; deadline is a u64 unix-ms timestamp. Same argument
/// conventions as swapExactInput (JSON integer or decimal string; floats
/// rejected). Returns the tx hash, or an empty string on failure (no pool,
/// unreadable AMM_PROGRAM_BIN, bad inputs, failed tx).
std::string swapExactOutput(const std::string& def_a_hex,
const std::string& def_b_hex,
const std::string& user_input_holding_hex,
const std::string& user_output_holding_hex,
const nlohmann::json& amount_out,
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);
/// Prices an `AddLiquidity` into the existing pool for (tokenAId, tokenBId) from the
/// two max deposit amounts. Reads the pool server-side (like the swap quotes) and runs
/// the guest's proportional-deposit math. Returns the same shape as `liquidityQuote`
/// minus the create-only locked LP: `{ status:"ok", error:"", amountARaw, amountBRaw,
/// expectedLpRaw, priceRaw }` — the actual ratio-matched deposits (display order), the
/// LP minted, and the pool's spot price. Slippage is applied at submit, not here.
/// `request` carries `{ tokenAId, tokenBId, maxAmountARaw, maxAmountBRaw }` (ids hex or
/// base58, normalized to hex; amounts a JSON integer or decimal string). On failure:
/// `{ status:"error", error:<code> }` — `invalid_token_id`, `config_missing`,
/// `bad_amount`, `no_pool`, `pair_mismatch`, `amount_too_low`, or `backend_error`.
LogosMap addLiquidityQuote(const LogosMap& request);
/// Submits an `AddLiquidity` transaction into the request's pool. `request` carries
/// `{ tokenAId, tokenBId, holdingAId, holdingBId, lpHoldingId, maxAmountARaw,
/// maxAmountBRaw, minLpRaw, deadlineMs }` (ids hex or base58, normalized to hex;
/// amounts/deadline a JSON integer or decimal string). `minLpRaw` is the caller's
/// slippage floor on the LP minted (the UI derives it from the quote's expectedLpRaw
/// and its slippage control). `lpHoldingId` is the holding that receives the minted LP.
/// On success: `{ status:"ok", error:"", transactionId:<hex tx hash> }`. On failure:
/// `{ status:"error", error:<code> }` — `config_missing`, `backend_error`,
/// `invalid_account_id`, `bad_amount`, `same_token_pair` (from `amm_pool_id` or the plan),
/// `wallet_submission_failed`, or a plan code (e.g. `no_pool`, `pair_mismatch`,
/// `config_unavailable`).
LogosMap addLiquidity(const LogosMap& request);
/// 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
/// account, every token, including zero-balance holdings. Narrowing to a
/// specific token is the selector's job. `wallet_open` gates the wallet read;
/// an empty list on a closed wallet, unset AMM_PROGRAM_BIN, or a decode failure.
/// (Thin stopgap — token-holding listing is wallet/token data; see
/// token_holdings.rs.)
LogosList tokenHoldings(bool wallet_open);
/// 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
/// TOKENS_CONFIG is unset / unreadable / not a JSON array.
LogosList tokenList();
/// New-position (add-liquidity) view state: reads the AMM config + the
/// user's wallet accounts and returns the new-position context map the
/// UI renders (available tokens, fee tiers, warnings). `wallet_open` gates
/// whether wallet accounts are included; `refresh_wallet_accounts` forces a
/// fresh read rather than a cached one.
LogosMap newPositionContext(const LogosMap& request,
bool wallet_open,
bool refresh_wallet_accounts);
private:
// Off-chain "network" context, derived from the process env (the same
// sources the app backend used): AMM deployment id from AMM_PROGRAM_BIN,
// configured token set from TOKENS_CONFIG. `status` is "ready" once the
// program id resolves, else "config_missing".
struct Network {
std::string id = "lez";
std::string status;
std::string fingerprint; // == amm_program_id (binds a quote to the deploy)
std::string amm_program_id; // 64-char lowercase hex
std::vector<std::string> token_ids;
};
// AMM_PROGRAM_BIN / TOKENS_CONFIG are fixed for the process lifetime, and
// this runs on the hot reply path (every op), so it resolves the program id
// + token ids ONCE and caches them (networkResolved). Cached only on
// success, so a startup miss (bin not readable yet) retries.
Network network();
// 64-char lowercase-hex AMM program id via the amm_ffi `program_id` op
// over the AMM_PROGRAM_BIN bytes (empty if unset/unreadable/bad).
std::string ammProgramId();
// Reads AMM_PROGRAM_BIN into a byte vector (empty on unset/unreadable/empty).
std::vector<uint8_t> loadAmmElf();
// Normalizes an account id given as 64-char hex or base58 to lowercase hex
// (base58 via the wallet module). Empty string if it is neither.
std::string normalizeAccountId(const std::string& id);
// Derives the config account id (amm_config_id) and reads it, returning the
// account-read shape the amm_ffi ops embed. Null json when the config_id
// op itself fails (readPublicAccount always yields at least {id,status}).
nlohmann::json readConfig(const Network& net);
// Reads a public account through the wallet module and returns the
// { id, status, account:{ program_owner, balance, nonce, data } } shape the
// amm_ffi ops expect (see the app-side accountReadJson). `account` is
// omitted when the read has no data (uninitialized/nonexistent).
nlohmann::json readPublicAccount(const std::string& account_id);
// The user's own public account reads (empty when the wallet is closed).
// Cached across calls (walletAccounts); `refresh` reloads instead of serving
// the cache — quote reuses it, submit forces fresh — since each read is a
// live sequencer round-trip.
nlohmann::json walletAccountReads(bool wallet_open, bool refresh);
// Process-lifetime network config, resolved once (see network()). Serialized
// module dispatch means no locking is needed; there is no invalidation, as
// runtime env reload is not supported.
bool networkResolved = false;
std::string programId;
std::vector<std::string> tokenIds;
// Cache of the user's public account reads for the context/quote path (each
// read is a live sequencer round-trip). Null until first read; `refresh`
// reloads it, and it's dropped when the wallet closes. See walletAccountReads.
nlohmann::json walletAccounts;
};