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).
The add-liquidity quote now takes slippageBps and returns minimumLpRaw =
floor(delta_lp * (10000 - slippage) / 10000) — the LP floor the submit passes as
min_amount_liquidity, mirroring the swap quotes' minReceivedRaw. Computing it in
Rust keeps the u128 slippage math out of the UI. Adds invalid_slippage (>= 100%)
and minimum_lp_zero (slippage leaves no floor) errors; the module's
addLiquidityQuote forwards slippageBps.
resolvePool now normalizes base58-or-hex ids (the liquidity view passes base58,
the swap card hex) and orients the returned reserves to the caller's requested
token order — reserveA is the requested tokenA's reserve — instead of the pool's
stored order, which needn't be canonical (e.g. a pool created outside the FFI,
like the testnet setup's). Callers read reserveA/reserveB directly; the swap card
is unaffected (it reads the reserves for display only and never oriented them
itself).
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.
Migrate the active-pool branch of the liquidity form onto the new addLiquidity
op (quoting stays on legacy quoteNewPosition for now, as agreed).
- AmmUiBackend: add the addLiquidity QtRO slot; forwards to the module and
refreshes balances (mirrors createPool).
- NewPositionFlow: route the active-pool confirm to addLiquidity, minting a
fresh LP holding then submitting {tokenA/B, holdingA/B, lpHolding, maxAmountA/B,
minLpRaw, deadline}. minLpRaw comes from the legacy quote's minimumLpRaw.
- NewPositionForm: show the per-side account selectors in add mode; gate the CTA
on both holdings and on the deposit amounts being present (hasDepositAmounts) —
the pair's probe quote otherwise reports canSubmit on simulated amounts and
wrongly enables the button before any amount is entered.
- amm_ffi: fix add_liquidity_plan to orient the (max amount, holding) pair to the
pool's STORED definition order, not is_canonical_pair — a pool created outside
the FFI (the testnet setup's spel new-definition) can store a non-canonical
order, which otherwise sent a holding into the wrong vault (Transfer
sender/recipient definition mismatch). Test uses a non-canonical pool.
Adds add-liquidity.mjs: asserts the CTA stays disabled with no amounts, submits
an add to the seeded A/B pool, and verifies reserveA grew on-chain.
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 spel-oriented guide for the token program, in the same style as the testnet
runbook but scoped to token operations only: create fungible definitions, hand
out holdings, transfer, mint, burn, rotate/renounce the mint authority, and
print NFTs.
Verified against main (4363f13) on the LEZ v0.2.0 pin: the regenerated IDL
matches artifacts/token-idl.json, all 63 token_program tests pass, and every
quoted abort message exists in programs/token/src.
Documents three constraints that are easy to hit and hard to infer:
- transfer's recipient and mint's holding are not signers, so a fresh account
cannot be claimed by them — initialize-account first.
- spel has no optional args or optional accounts: Option<account_id> flags are
always required (pass 'none'), and authority ops are split into self vs
*-with-authority variants.
- new-definition-with-metadata is unusable from the CLI in spel v0.5.0 and
v0.6.0 — its serializer has no encoding for IDL 'defined' types.
Add per-side account selectors to the create-pool form, sourced from the
wallet's token holdings, so a user holding a token in several accounts chooses
which funds each deposit. A single holding auto-selects.
Mirrors the swap card: the selector lives inside the token input card, below
the token button. AmmTokenAmountSurface gains an optional footer slot (unchanged
when unset, so add-liquidity renders as before); TokenAmountInput fills it with a
ProgramAccountSelector filtered on the token's base58 definitionId.
- LiquidityPage fetches backend.tokenHoldings(), refetching when the wallet opens
- NewPositionForm routes the chosen holdings into submissionSnapshot's createPool
call; canConfirm now requires both holdings when creating a pool (add-liquidity
is untouched — it enumerates holdings server-side)
- create-pool.mjs selects the funding account for each side before submitting
canSubmit now requires a chosen holding on both swap sides, so the swap e2e
test must select them first. The selector auto-selects a single holding, but
pick it explicitly (robust to multi-account wallets) via a new selectAccount
step that waits for the holdings to load and selects the first match.
Expose each side's selector via selectorObjectName (swapSell/BuyAccountSelector)
and surface sellHolding/buyHolding in the failure diagnostics.
Add a per-slot account selector to the swap card's sell/buy inputs, sourced
from the wallet's token holdings, so a user holding a token in several accounts
chooses which funds each side. A single holding auto-selects.
- SwapPage fetches backend.tokenHoldings(), refetching when the wallet opens
- TokenInput embeds ProgramAccountSelector (half width, right-aligned) in a
full-width row below the amount/token row; exposes it as selectedHoldingId
- SwapCard drives swapExactInput/Output with the selected holdings and blocks
submit ("Select token accounts") until both are chosen
- ProgramAccountSelector gains showWhenSingle + textAlignment opt-ins
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.
A shared QML select box for choosing a program account (a token holding) that
matches given criteria (accountType + a state field such as the token
definitionId), listing matches with balances and emitting selectionChanged.
Registered in the wallet module's public QML.
Cherry-picked from feat/shared-wallet-token-holding-selector, reduced to the
reusable component only; the AMM swap/liquidity wiring and FFI account source
are reimplemented on the current (post swap + createPool) code.
- setup-amm-testnet.sh: mint a third token (TKC), appended to ACCOUNT_LABELS so
the deterministic a/b/lp ids don't shift and left unseeded (only A/B is created)
for the test to create the A/C pair. Split wallet restore from account
registration (ensure_accounts always runs) so adding a token needs no re-restore.
- create-pool.mjs: drives the Liquidity view to create the A/C pool, then verifies
it on-chain via the swap card's resolvePool. evaluate() fallbacks for the
submit/confirm buttons (synthetic clicks aren't reliable on QtQuick Buttons).
- Adds test-hook objectNames (newPositionSubmitButton, liquidityConfirmDialog).
Route pool creation through the redesigned createPool op and retire the old
create-flow machinery. Add-liquidity stays on the legacy submitNewPosition.
Module (amm_module):
- createPool becomes a single-param envelope: it reads the caller-provided
lpHoldingId from the request (a new pool has no pre-existing LP holding),
dropping the requires-fresh-lp handshake — the module never creates wallet
accounts. Returns { status, error, transactionId }; unlike the swaps, a
submit failure carries a code so the UI can explain why.
Backend (AmmUiBackend):
- Expose liquidityQuote (read-only preview) and createPool slots. createPool
guards on the app's wallet-open state, forwards the request, and refreshes
balances on success. No account creation here.
UI (liquidity flow/form):
- NewPositionFlow.confirm() branches on the missing-pool signal: create ->
createPool (mint a fresh public LP account via createAccountPublic, then
submit); add -> unchanged submitNewPosition. Hex transactionId accepted.
- submissionSnapshot supplies canonical-order holdingAId/holdingBId.
- (liquidityQuote is wired to the backend but the create preview still rides
the legacy quote for now.)
Cleanup: the pool-creation confirmation poll is orphaned now that create no
longer submits via submitNewPosition. It polled the pool account by re-quoting
until poolStatus flipped to active_pool — a stopgap for the missing
transactionStatus/poll_tx on the lez module. Removed pendingPoolProbes,
poolPoller, watchPoolCreation, pollPendingPool, finishPoolProbe,
rotate/removePendingPool, pairKey, matchesSelectedPair,
selectedPoolCreationPending, poolActivated, the poolCreationPending state,
acceptPoolActivation, and their now-obsolete tests.
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.
Wire the Buy direction to submit: AmmUiBackend gains a swapExactOutput slot
(guarded like swapExactInput), SwapCard.executeSwap branches on direction —
sell -> swapExactInput(minReceivedRaw), buy -> swapExactOutput(maxInRaw) — and
canSubmit/submitButtonText/buildSnapshot handle both. The confirmation dialog
switches wording by mode ("You pay at most" / "You receive exactly" for exact
output). The Buy field is now digitsOnly since its value is submitted as a raw
base-units integer.
With both directions priced and oriented by the module, the client no longer
needs any pool math. Remove the reserve-orientation chain (sellIsPoolA,
buy/sellReserveNum, poolReserveA/B, poolDefAHex) — resolvePool now reports only
existence and fee — and the now-dead helpers (formatBaseUnits/formatAmountValue,
DummySwapState.amountInFor/minReceived/priceImpactPercent/maxSent). The
impossible-swap guard moves from a client reserve compare to the module's
output_exceeds_liquidity error, surfaced as "Insufficient liquidity".
Wire the Buy direction of the swap card to the module's server-side
swapExactOutQuote, mirroring the exact-input path. AmmUiBackend gains a
swapExactOutQuote(tokenIn, tokenOut, amountOutDecimal, slippageBps) slot
returning { requiredInRaw, maxInRaw, priceImpactBps } (read-only).
Editing the Buy amount now debounces a swapExactOutQuote call and sources the
required input (shown in the Sell field), the price impact, and the slippage
ceiling from it — the exact figures come straight from the quote's raw integer
strings, so the preview matches execution and no reserve orientation happens
client-side. A retyped amount invalidates the quote up front.
SwapSummary's last row is generalised from a hardcoded "Min received" to a
direction-aware bound (boundLabel/boundText): "Min received" (min out) for
exact input, "Maximum sent" (max in) for exact output. SwapConfirmationSummary
is updated for the renamed property.
The Buy field remains preview-only — canSubmit is still sell-only, pending the
exact-output submit wiring. The now-orphaned DummySwapState pricing helpers are
left for a follow-up cleanup.
Wire the Sell direction of the swap card to the module's server-side quote
instead of the client-side DummySwapState estimate. AmmUiBackend gains a
swapExactInQuote(tokenIn, tokenOut, amountInDecimal, slippageBps) slot that
forwards to amm_module and returns { expectedOutRaw, minReceivedRaw,
priceImpactBps } (read-only, no wallet guard).
SwapCard debounces a swapExactInQuote call as the user types a Sell amount and
sources the expected output, min received, and price impact from it. The exact
figures shown (buy field, confirmation snapshot) and the submitted min_out come
straight from the quote's raw integer strings, so the preview can't drift from
execution and the client no longer orients reserves (fixes the #236 defAHex
bug) or recomputes min_out in double/BigInt. A retyped amount invalidates the
quote immediately and blocks submit until the re-quote lands.
The Buy direction is unchanged — still a local DummySwapState preview, pending
the exact-output wiring. resolvePool stays as the source of pool existence, the
fee row, and the Buy-side reserves.
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.
The plan op is internal plumbing — its only consumer is the module's
swapExactInput, which just forwards the four tx-submission fields to the
wallet. Collapse its response onto the FFI envelope: return { programId,
accountIds, signingRequirements, instruction } directly and route the
recoverable domain failures (same_token_pair, config_unavailable) through
Err -> { ok: false, error } instead of a nested { status, code } object.
Also drop the deadlineMs echo — it's unused (the deadline is already encoded
in the instruction bytes). swapExactInput now treats any non-ok plan as a
failure. No behavior change for the UI: the plan JSON never crosses the
module boundary.
The swap-submission plan derived the pool's vaults from the canonical token
order (compute_vault_pda(pool, canonical_token_a/b) via derive_pair), but the
guest asserts the provided vaults against the pool's stored vault_a_id /
vault_b_id, which are in the pool's *creation* order. compute_pool_pda_seed
canonicalizes, so the pool address is order-independent, but NewDefinition
stores def_a/def_b (and their vaults) as created — so for a pool created in
non-canonical order, the plan put vault_b in the vault_a slot and the guest
panicked with "Vault A was not provided", reverting the swap.
Read the pool account in swapExactInput and pass its data to
swap_exact_in_plan, which now uses pool.vault_a_id / pool.vault_b_id verbatim
for the vault slots (pool / current_tick / clock stay from derive_pair, since
those are order-independent). This restores the order-agnostic behavior the
original swap client had before it was rewired onto the canonical derive_pair
in 737b2f6.
Adds a regression test that builds a non-canonically-created pool (stored
def_a = the smaller-valued token) and asserts the plan emits the pool's stored
vaults, guarding that they differ from the canonical derivation.
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.
Lift the inverse constant-product SwapExactOutput math out of the guest's
exact_output_swap_logic into amm_core::swap_exact_out_amounts(amount_out,
reserve_in, reserve_out, fee_bps) -> Option<(effective_in, required_in)>:
ceil(reserve_in * amount_out / (reserve_out - amount_out)) lifted through the
fee via mul_div_ceil. exact_output_swap_logic now calls it and keeps its
nonzero/exceeds-reserve asserts. Behavior-preserving (the panic-message tests
still pass); None (out >= reserve or zero fee multiplier) surfaces as the
existing expect.
Makes the on-chain exact-output pricing one reusable function so the
off-chain quote can produce byte-identical required-input figures instead of
re-deriving the inverse formula. Mirrors swap_exact_in_amounts.
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.
Lift the constant-product SwapExactInput math out of the guest's swap_logic
into amm_core::swap_exact_in_amounts(amount_in, reserve_in, reserve_out,
fee_bps) -> (effective_in, amount_out). swap_logic now calls it and keeps its
nonzero input/output asserts. Behavior-preserving (the two panic-message
tests still pass); the only change is that the impossible-overflow expects
become saturating.
Makes the on-chain pricing one reusable function so the off-chain swap quote
can produce byte-identical expected-output figures instead of re-deriving the
formula.
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).
UpdateConfig let the admin rewrite token_program_id and
twap_oracle_program_id in place. Both are immutable deployment
parameters — twap_oracle_program_id derives the current-tick PDA and
every price-observation/price account PDA, and token_program_id is the
program the AMM issues its vault transfers to — so changing either
after any pool exists would orphan every derived account and vault. A
genuine change requires redeploying the AMM, never an in-place edit.
- Instruction::UpdateConfig now carries a single required field,
new_authority; the two program-id fields are removed.
- update_config assigns the new authority directly (no Option/if-let);
PDA + admin + signature preconditions unchanged.
- Guest handler and IDL updated to match.
- Drop the integration test that mutated token_program_id (it
exercised the vulnerability); keep reject-non-admin and
authority-handoff, and assert program ids survive a transfer.
BREAKING CHANGE: the UpdateConfig instruction ABI changed — the
token_program_id and twap_oracle_program_id fields are removed and
new_authority is now required (was Option). Any client constructing
UpdateConfig must be updated. The instruction enum change also alters
the program ImageID: redeploy and update every ImageID-derived value
(deployed program ids, client/config files, PDA-derived addresses,
AMM/ATA program-id inputs) before submitting
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)