Add-liquidity minted a fresh LP account every time, fragmenting a position
across holdings. The New position form now has an LP-destination selector (the
same Input-mode component as the token funding rows): add-liquidity preselects
the wallet's existing LP holding so deposits consolidate, while create-pool has
none and mints a fresh one.
- addLiquidityQuote returns lpDefinitionId (base58) so the form matches holdings
- createPool/addLiquidity submit into the chosen holding, else create-fresh
- e2e: add-liquidity waits for the preselect; create-pool asserts fresh-account
The `*Raw` suffix on the module's amount/price/balance/LP fields was
redundant — every such field is already a base-unit integer, and there was
no formatted sibling to disambiguate from. Drop it across the whole wire
contract in lockstep: the amm_ffi request/response fields (snake_case
`amount_in_raw` → `amount_in`, serde `rename_all="camelCase"` keeps the JSON
keys mapped), the C++ module API, the QtRO `.rep`, the QML/app that consumes
it, the mjs tests, and the module README.
Examples: expectedOutRaw→expectedOut, minReceivedRaw→minReceived,
maxInRaw→maxIn, requiredInRaw→requiredIn, priceRaw→price, reserve{A,B}Raw→
reserve{A,B}, amount{In,Out}Raw→amount{In,Out}, expectedLpRaw→expectedLp,
lpAmountRaw→lpAmount, {max,min,minimum,actual}Amount{A,B}Raw, minimumLpRaw,
minLpRaw, selectedBalance*Raw, totalSupplyRaw, quote*Raw. This also unifies a
pre-existing inconsistency where resolvePoolAccount already emitted `reserveA`
and resolveTokens already emitted `balance`.
Kept where a formatted UI sibling of the same base name exists, so `Raw`
still disambiguates the base-unit value: amountARaw / amountBRaw (vs the
user-input `amountA`/`amountB`), balanceRaw (vs display `balance`), and
initialPriceRaw (vs formatted `initialPrice`). Also kept the format-boundary
helpers formatRaw / rawLpText / probeRaw / displayRaw / displayQuoteRaw /
boundRaw.
BREAKING: the `amm_module` public API field names change (logoscore /
Basecamp / QtRO consumers must update).
The module already had removeLiquidityQuote and removeLiquidity, but
AmmUiBackend never forwarded them, so QML had no way to reach them. Expose
both, mirroring the add pair: the quote is read-only and unguarded, the
submit is wallet-guarded and refreshes balances once the withdrawal lands.
Unlike createPool/addLiquidity nothing fresh is created -- the request names
the existing LP holding to burn and the two holdings that receive.
The pool detail view's secondary action reads "Add liquidity" until the
wallet holds LP for that pool, then becomes "Manage position" with a
dropdown offering both directions, opening on hover with the same deferred
close the nav bar's tab menu uses. Remove is disabled until the LP account
and both receiving holdings resolve.
RemoveLiquidityDialog is a modal sheet: 25/50/75/Max presets over a slider,
a debounced quote of both withdrawals, the post-slippage floors, submit. The
quote is generation-tagged because dragging the slider fires quotes faster
than they return and a late reply must not paint over a newer percentage.
The submit passes the quote's own minimumAmount*Raw as its floors, so it
enforces what the preview promised. 100% burns the balance exactly; every
other percentage floors, so rounding can't push the request past it.
The two views listed different tokens: swap rendered tokenList()
(TOKENS_CONFIG verbatim, no chain check) while liquidity rendered
resolveTokens(), which reads each id on-chain and silently drops any whose
definition isn't a readable fungible token owned by the configured
TokenProgram.
Stage Logos.Wallet at the plugin root as pure QML (Basecamp rejects the
prefer :/qt/qml/... resource), move NavBar into qml/chrome/ so it stops
colliding in Basecamp's shared import path, and add a QML_IMPORT_PATH
wrapper so standalone still resolves the root module. Adds amm-ui-lgx /
amm-module-lgx aliases for addressable Basecamp installs.
Moving the token list app-side (readTokensConfig) dropped the module
tokenList()'s base58->hex normalization, so a token's definitionId now
reaches the swap view in whatever encoding TOKENS_CONFIG uses — base58 in
practice. The swap account selector filtered holdings on the hex
definitionIdHex field using that value, so a base58 id matched nothing and
every token showed "No funds", blocking swaps.
Make the selector encoding-aware: a 64-char hex id filters definitionIdHex,
otherwise the base58 definitionId. tokenHoldings already emits both encodings
per holding, so this matches whichever the config uses, needs no app-side
base58 decoder, and mirrors how the liquidity view already filters. The swap
quote/submit path already normalizes base58->hex in the module, so nothing
else changes.
Token discovery is an app concern, not module business — same rationale as
poolList reading AMM_POOLS_CONFIG. Drop tokenList() from amm_module and have the
app read the config itself.
Expose the two TWAP oracle-setup instructions as module ops so a pool's price feeds
can be seeded from the app. Both chain into the configured oracle, seeded from
validated pool state (initial tick read on-chain) — nothing is caller-priced, and
each window is a distinct feed account.
Expose the authority-only UpdateConfig as a module op so the admin can transfer
AMM ownership. The guest change (UpdateConfig restricted to the current admin) is
already shipped; this is the module wrapper.
Return the pool's full derived state from one read instead of just existence +
reserves, so callers get the derived accounts (for future account views / oracle
setup) without re-deriving.
FFI resolve_pool: drop the `exists` boolean — the presence of data is the signal.
An existing pool returns { status:"ok", ..., poolId, defAHex, defBHex, vaultAId,
vaultBId, lpDefinitionId, reserveA, reserveB, liquiditySupply, feeBps }; a missing /
uninitialized pool is the { status:"error", error:"no_pool", poolId } error (still
carrying the derived poolId for address derivation).
Module: resolvePool -> resolvePoolAccount — { status:"error", error } envelope for
hard failures, and orient reserves + defs + vaults to the caller's requested order.
Backend + QML: rename the slot; SwapCard and NewPositionFlow switch the existence
check from pool.exists to pool.status === "ok" (reserve field names unchanged, so
no other consumer edits). no_pool still routes to create-pool; hard errors still
surface.
The liquidity token surface moved app-side (resolveTokens + custom tokens),
leaving the whole newPositionContext path dormant. Delete it end to end and
point the swap methods at the same lean program-id helper everything else uses.
The Copilot Autofix squash reworked addCustomToken to persist the canonical
definitionId (and to fail when the store write fails), but it deleted the line
that resolves the token — `const QVariantList rows = resolveTokens(probe, …)` —
while still calling `rows.first()`, and it dropped the function's closing brace.
The result didn't compile: `rows` was undefined and customTokenStorePath parsed
as a nested definition ("function definition is not allowed here").
Restore the resolveTokens call and the `rows.isEmpty()` guard before `rows` is
used, and re-add the closing brace. The autofix's intent is preserved: resolve
the pasted id, keep the canonical definitionId, persist it, and surface a
backend_error if saveCustomTokenIds fails
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().
The Pools page shipped with a hardcoded four-pair sample. Replace it with a
config-driven "known pools" list, mirroring how the Swap token picker reads
TOKENS_CONFIG: the app loads a flat JSON array from the AMM_POOLS_CONFIG
environment variable and renders one row per entry. Adding pairs is a config
edit — no app change.
Pool discovery is an app concern, so the config is read in the backend
(AmmUiBackend::poolList, Qt JSON) rather than the amm_module — the module is
shedding app-specific view surface (tokenList/newPositionContext), so pools go
where tokens are heading, not where they are today. poolList() fails soft to an
empty list when AMM_POOLS_CONFIG is unset/unreadable/not an array, and skips
individual entries missing tokenA/tokenB/a numeric feeBps.
Each entry carries the display symbols (tokenA/tokenB), feeBps, and the on-chain
identifiers (poolId, tokenADefinitionId, tokenBDefinitionId) so a row can later
be resolved against chain state. PoolsPage takes injected backend/runtime and
loads via runtime.watch(backend.poolList()); the Repeater renders entries
generically.
The AMM testnet setup script now emits amm-pools.json from a POOL_SPECS array
(one line per seeded pool, currently the seeded TKA/TKB pool) and prints
AMM_POOLS_CONFIG in the launch instructions. Adds amm-pools.json.example, a
README section, and gitignores the runtime config files.
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`.
Cleanup surfaced while auditing the lean-quote migration:
- Remove the `quote_changed` / `quote_not_submittable` error messages — relics of
the dropped quoteHash/canSubmit commitment model, emitted by nothing now.
- Remove the `network_unknown` / `network_mismatch` messages — the `Network`
model is gone, so they're never emitted.
- warningText: drop the dead `quotePayload.warnings` branch (lean quotes carry no
warnings); keep the live token-sourcing-context warnings.
The token selector let you pick the same token on both sides of a swap.
That drove resolvePool into amm_client_pool_pda with def_a == def_b, which
hits `panic!("Definitions match")` in amm_core (a pool needs two distinct
tokens). Because that panic crosses the `#[no_mangle]` FFI boundary — which
can't unwind — it aborts, taking the whole UI process down.
Guard it at the source: the picker now disables (dims, no hover/click, tags
"Selected") whichever token is already chosen on the opposite side, so the
two sides can never match.
- TokenListItem: add a `disabled` state (opacity, inert MouseArea, tag)
- TokenSelectorModal: add `disabledDefinitionId`; gate both the popular-token
pills and the list rows on it
- SwapPage: on open, set it to the opposite side's selected token
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).
Both liquidity branches now quote through the lean composable ops. The create
path joins the add path (already on addLiquidityQuote) by reworking
liquidity_quote into a dual-mode create quote and wiring the form to it via
the resolvePool pool read. quoteNewPosition/amm_quote are no longer reached
from the UI.
FFI (modules/amm/ffi):
- liquidity_quote is dual-mode: price-only (initialPriceRealRaw, no amounts)
returns the minimum opening deposit via minimum_opening_pair; supplied
amounts return the actual deposit with the price derived from them. Emits
actual/minimum amounts, expectedLp, lockedLp and the Q64.64 price.
- LiquidityQuoteRequest gains initial_price_real_raw (Option<String>, needed
only in price-only mode).
Module (modules/amm/src):
- liquidityQuote forwards initialPriceRealRaw to the op.
UI (apps/amm/qml):
- Route create-vs-add on the pool read (resolvePool -> poolExists); create
quotes via liquidityQuote, assembled into the missing-pool shape the form
already consumes.
- poolStatus moves off the quote onto the flow's poolExists; the form derives
activePool/missingPool from it. Trim the vestigial quote fields (canSubmit,
requiresFreshLp, warnings, errors[], accountPreview, the "Pool" row) and drop
the account-plan panel for parity with the swap view.
- Fix a real bug: a pair change now resets poolExists (resetPoolExistence) so
resetPairDraft re-resolves the pool like a fresh selection. Otherwise an
active pool kept stale (cleared) reserves with no re-quote, and the deposit
ratio-fill silently no-op'd.
Tests (apps/amm/tests):
- Read activePool instead of the removed poolStatus. The add test waits for the
reset's active-pool quote to settle (reserves reloaded) before the ratio-fill;
the create test resets the draft to clear leftover cross-run amounts and the
stale submitted transactionId.
Wire the liquidity view's active-pool preview onto the lean addLiquidityQuote +
resolvePool, off the legacy quoteNewPosition. Create-pool quoting stays legacy
for now.
- Expose addLiquidityQuote as a QtRO slot + backend forwarding.
- NewPositionFlow.requestQuoteNow routes on resolvePool.exists (existence, like
the swap card — no quote-derived poolStatus): active -> addLiquidityQuote,
assembled into the shape the form consumes (reserves/fee from resolvePool,
minimumLpRaw from the quote); missing -> legacy quoteNewPosition.
- Drop the obsolete quoteHash gate from canConfirm (the lean quotes are
stateless).