Move the liquidity token selector off the module's stateful newPositionContext
onto a lean, app-owned surface, and let users add unlisted tokens by id.
FFI: new stateless `resolve_tokens` op — the app passes an explicit id set and
gets uniform selector rows `{ definitionId (base58), name, totalSupply, holdingId,
balance }`, held tokens first, unresolvable/non-fungible ids omitted. Reuses the
per-token definition/holding logic from `context`, without the network/status
envelope. Unit-tested.
Module: `resolveTokens(request, wallet_open)` reads the definitions + wallet and
calls the op (ids wrapped in a map — the universal-module glue only marshals
map/scalar inputs, not bare lists).
Backend: the app owns the id set — configured tokens (TOKENS_CONFIG) plus the
user's persisted custom ids. Held-but-unlisted tokens are NOT auto-listed (the
list mirrors the swap side); a token you hold still shows its balance once listed.
`addCustomToken` validates a pasted id by resolving its on-chain definition, then
persists it to CUSTOM_TOKEN_CONFIG (defaulting to the per-user app-data store, with
a HOME fallback so persistence never silently no-ops on an empty path).
QML: NewPositionForm/LiquidityPage take tokens/walletReady/loadingTokens as inputs
and drive selection + custom-token resolution through the backend; dropped all
newPositionContext reads and the selectable/status/code row fields.
Tests: custom-token.mjs creates token D on-chain (left out of the token config)
and verifies pasting its id resolves, selects, and persists it across a reload.
The setup script mints token D and initializes/prints the isolated
CUSTOM_TOKEN_CONFIG store
The liquidity form's fee-tier selector was fed from the module's
newPositionContext, which hardcoded an empty list — leaving the selector
blank. Source the tiers from the program instead so the UI can never
drift from what the guest accepts.
Add amm_core::SUPPORTED_FEE_TIERS: the canonical ascending list of raw
bps ([1, 5, 30, 100]), built from the existing FEE_TIER_BPS_* constants.
is_supported_fee_tier's match is left unchanged and the new const is
unused on-chain, so the guest ImageID is unaffected; a drift-guard test
locks the list to the check (every entry accepted, neighbours rejected,
ascending/deduped).
Wire it through the stack:
- FFI: amm_fee_tiers op reading SUPPORTED_FEE_TIERS -> { feeTiers: [...] }
(empty FeeTiersRequest, cbindgen header regenerated).
- Module: LogosList feeTiers() unwrapping the list, like tokenHoldings.
- Backend: QVariantList feeTiers() QtRO slot forwarding to the module.
- QML: LiquidityPage fetches backend.feeTiers() once (wallet-independent)
and injects it into NewPositionForm, which wraps each int into a
{ feeBps } row for the existing delegate. Drop the now-dead feeTiers
key from the flow's loadingContext().
Two naming cleanups on the create-pool quote, aligning it with the add / remove
counterparts (per modules/amm/INTERFACE.md). Pure renames — no behavior change.
- `liquidityQuote` → `createPoolQuote` across the stack: the FFI op
(`liquidity_quote` → `create_pool_quote`, `LiquidityQuoteRequest` →
`CreatePoolQuoteRequest`, `amm_liquidity_quote` → `amm_create_pool_quote`,
cbindgen header regenerated), the module method, the AmmUiBackend slot, and the
QML call site. It really is the create-pool quote — `addLiquidityQuote` /
`removeLiquidityQuote` are the other branches — so the old name misled.
- `initialPriceRealRaw` → `priceRaw` (request field `initial_price_real_raw` →
`price_raw`): drops the legacy "Real" and unifies the price key with the add /
remove quotes, which already return `priceRaw`. Create, add, and remove quotes
now all speak `priceRaw`; the create-vs-add routing in NewPositionFlow keys on
`request.priceRaw`.
A permissionless keeper op that refreshes a pool's stored reserves to the live
vault balances (and its TWAP tick). Same lean pattern as the other plans, but
minimal: SyncReserves is a unit instruction — no quote, no user inputs (no
amounts/slippage/deadline/holdings), and nothing signs.
FFI (modules/amm/ffi):
- sync_reserves_plan: encodes SyncReserves over the fixed 6-account IDL order
(config, pool, vault_a, vault_b, current_tick, clock), all non-signing.
config/pool/current_tick/clock are order-independent PDAs from derive_pair;
the vaults come from the pool's stored ids in pool_data (read-only, but the
guest still asserts them — a non-canonically-stored pool would otherwise
mismatch). Fails closed: same_token_pair, config_unavailable, no_pool.
- Wired through mod.rs / ffi.rs (cbindgen header regenerated). Tests cover the
stored-vault + zero-signer + unit-instruction layout and the fail-closed
paths. amm_ffi: 43 tests pass, clippy clean. (lib.rs is a cargo fmt re-wrap.)
C++ module (modules/amm/src):
- syncReserves reads config + pool server-side, calls the plan, and submits.
request is just { tokenAId, tokenBId } — no holdings/amounts/deadline. Public
method → auto-exposed via the universal-module dispatch.
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.
Both liquidity branches now quote through the lean ops (liquidityQuote /
addLiquidityQuote), so quoteNewPosition and the heavy amm_quote machinery it
drove are unreachable. Remove them end to end.
FFI (modules/amm/ffi):
- Drop the amm_quote entry point and the whole quote-evaluation graph:
api/{accounts,commitment,funding,position}.rs, the QuoteRequest /
PositionRequest / PairSnapshot request types, quote_error::fatal_quote, and
api/clock.rs (its decode_clock was quote-only). quote.rs keeps only the shared
opening-deposit math (minimum_opening_pair + helpers) that liquidity_quote
reuses.
- Trim the fields the quote path was the sole reader of: SelectedHolding.account
and PairIds.{token_program,twap_program}.
- Drop the quote-path unit tests; keep the math / pair / context / holding /
swap ones (37 pass, clippy clean).
Module (modules/amm/src):
- Remove AmmModuleImpl::quoteNewPosition and its buildQuoteInput snapshot helper.
App (apps/amm):
- Remove the AmmUiBackend quoteNewPosition slot (.rep/.h/.cpp) and the dead QML
backend mock + obsolete fresh-quote test.
- finishSubmitFailure no longer keeps a submit-returned re-quote (the lean submit
ops never return one); it always re-quotes on failure.
- submissionSnapshot drops the always-empty quoteHash and derives the confirm
dialog's action from the resolved pool state instead of the dead
quotePayload.instruction (restores the "Create pool" / "Add liquidity" label).
Now that add-liquidity and pool creation submit via addLiquidity / createPool,
the legacy submitNewPosition path is dead. Remove it end to end.
App backend (AmmUiBackend):
- Drop the submitNewPosition slot/method and its newPositionError helper.
Module (AmmModuleImpl):
- Drop submitNewPosition, the m_requestPending guard, and the now-orphaned
nowMs/parseU64 helpers (+ the <chrono> include).
FFI (amm_ffi) — the plan op only submitNewPosition called:
- Delete plan.rs; remove amm_plan (extern + regenerated header), api::plan,
and PlanRequest.
- Remove the plan-only machinery it fed: QuoteBranch, NewPositionPlan,
EvaluatedQuote.plan/quote_hash, and AccountPlan's wallet_args /
requires_fresh_lp / contains / validate_ready, plus the plan construction
in quote.rs.
- Tests: drop the plan-only tests/helpers; keep quote coverage by trimming
the mixed tests to their quote assertions.
QML tests (tst_LiquidityPage):
- Remove the two legacy-submit tests (base58-only success) + the
submitNewPosition mock and its now-unused fixtures. Keep the
finishSubmitFailure test (unchanged behaviour).
quoteNewPosition and its machinery (amm_quote, buildQuoteInput, PairSnapshot,
AccountPlan preview/sources, commitment) stay — they retire with the legacy
quoting in the quote-migration vertical.
Introduce the add-liquidity vertical mirroring the swap + createPool patterns,
for depositing into an existing pool via the AddLiquidity instruction.
FFI (amm_ffi):
- add_liquidity_quote — decode poolData, orient the caller's max amounts to the
pool's canonical order, run the guest's exact ideal->actual->delta_lp math, and
return { amountARaw, amountBRaw, expectedLpRaw, priceRaw } in display order.
Slippage-free like create's quote; the min-LP floor is applied at submit.
- add_liquidity_plan — canonicalize (token, max-amount, holding) as one unit,
take vaults + LP definition from poolData, emit the 10-account AddLiquidity
order (only the user holdings a/b/LP sign). Takes minLpRaw directly, like
swap_exact_in_plan takes min_out.
Shared with createPool: extract canonical_triples (the pair/amount/holding
canonical swap) and plan_response (the tx-submission envelope); both the create
and add plans now use them.
A thin source for the account selector: an amm_ffi op that decodes the wallet's
fungible TokenHoldings (owned by the configured token program) into
[{ 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. Exposed via AmmModuleImpl::tokenHoldings and the AmmUiBackend tokenHoldings()
slot, both gated on wallet-open.
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.
Server-side SwapExactOutput submission, mirroring the exact-in path. The
swap_exact_out_plan FFI op resolves the pool via the shared derive_pair,
encodes amm_core::Instruction::SwapExactOutput { exact_amount_out,
max_amount_in, deadline }, and returns the same four tx-submission fields as
swap_exact_in_plan (programId, accountIds, signingRequirements, instruction)
in the identical fixed 8-account IDL order — only the user's input holding
signs. Recoverable domain failures (same_token_pair, config_unavailable) go
through the FFI envelope as Err. C export amm_swap_exact_out_plan (cbindgen
header regenerated).
The module swapExactOutput(defA, defB, userIn, userOut, amountOut, maxIn,
deadline) method reads the AMM config, calls the op, and submits the plan via
send_generic_public_transaction, returning the tx hash (empty string on
failure). Same argument conventions and I/O-free split as swapExactInput.
Not yet wired into AmmUiBackend/QML — the SwapCard rewire is a follow-up. The
existing swap UI path is untouched, so nothing breaks.
Rename the swap-submission planning op for symmetry with the exact-in/exact-out
split already applied to the quote ops (swap_exact_in_quote /
swap_exact_out_quote): swap_plan -> swap_exact_in_plan, SwapPlanRequest ->
SwapExactInPlanRequest, and the C export amm_swap_plan -> amm_swap_exact_in_plan
(cbindgen header regenerated). The module's swapExactInput call site and trace
are updated to match.
Server-side SwapExactOutput preview. The swap_exact_out_quote FFI op orients
the pool reserves to the requested in/out direction, prices via the shared
amm_core::swap_exact_out_amounts (so requiredIn matches the chain), and
derives the slippage ceiling: { requiredInRaw, maxInRaw, priceImpactBps }.
no_pool and output_exceeds_liquidity (amount_out >= reserve) are returned as
errors. Read-only — no quoteHash; the on-chain max_amount_in is the real
guard.
The module swapExactOutQuote(tokenIn, tokenOut, amountOut, slippageBps)
method derives the pool via the config-free pool_id op, reads it, and wraps
the op in the { status, error, ... } envelope. Mirrors swapExactInQuote.
Not yet consumed by the QML swap view, so nothing breaks. Lets the buy field
stay editable when the SwapCard is rewired in a follow-up.
Server-side SwapExactInput preview. The swap_exact_in_quote FFI op orients the pool reserves to the requested in/out direction, prices via the shared amm_core::swap_exact_in_amounts (so expectedOut matches the chain), and derives the slippage floor: { expectedOutRaw, minReceivedRaw, priceImpactBps }. no_pool is returned as an error; pool metadata (reserves/fee) comes from resolve_pool, so it isn't echoed. Read-only — no quoteHash; the on-chain min_amount_out is the real guard.
The module swapExactInQuote(tokenIn, tokenOut, amountIn, slippageBps) method derives the pool via the config-free pool_id op, reads it, and wraps the op in the { status, error, ... } envelope; call() now surfaces the op error code so no_pool propagates.
The QML swap view still uses the old path, so nothing breaks. Will make the QML consume it in a follow-up.
Derives a pool's PDA from the AMM program id and the two token ids —
compute_pool_pda(amm_program, canonical(token_in, token_out)) — returning
{ poolId }. Tokens may be given in either order; the op canonicalizes.
Unlike swap_pair, which derives the whole pair account-set (including the
current-tick PDA, which depends on config.twap_oracle_program_id and so
requires reading the config account), the pool address depends only on the
program id and the token pair. So a caller that just needs to locate and
read the pool can skip the config read entirely.
Exposed as the amm_pool_id C ABI export. Additive — no existing op changes.
It backs the config-free pool lookup the upcoming swap-quote path needs (and,
later, resolvePoolAccount).
After the amm_module refactor, this crate is linked only by the module (the
UI delegates to modules().amm_module and links nothing), so its home under
apps/amm/ and the name "client" were both misnomers: it's the AMM business
logic the module wraps, reached across an FFI boundary — the same relationship
logos_execution_zone has with wallet_ffi. Co-locate it with the module that
owns it and name it for that role.
The FFI surface is unchanged — the exported functions are already amm_* (not
amm_client_*) — so only the crate, directory, generated header, dylib, and
package names move. The module's call sites are untouched; it just includes the
renamed header.
- apps/amm/client → modules/amm/ffi (git-tracked rename; history preserved)
- crate amm_client → amm_ffi: package name, include/amm_ffi.h, libamm_ffi.dylib,
AMM_FFI_H guard, build.rs output path, tests/public_api.rs import
- workspace member path + Cargo.lock
- flake.nix: pname, -p, header-copy path, dylib install_name, packages.amm_ffi,
ammModuleOutputs externalLibInputs, DYLD wrapper
- modules/amm: metadata external_libraries, CMakeLists EXTERNAL_LIBS, impl
#include + comments, README, flake note
- apps/amm/flake.nix: drop the now-dead amm_client external-lib input (the UI
links no external lib of its own)
Resulting layout:
modules/amm/
src/ # C++ module (amm_module_impl.{h,cpp})
ffi/ # Rust crate amm_ffi (Cargo.toml, src/, include/amm_ffi.h)