Files
lez-programs/modules/amm
r4bbit 62d133e909 feat(amm): let LPs choose the LP-token destination account
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
2026-08-21 15:35:27 +02:00
..

AMM core module

amm_module — the AMM business logic as a headless Logos core module (interface: "universal"). It exposes the AMM's on-chain operations so they can be driven identically from the QML UI (apps/amm, via the generated modules().amm_module caller) and from the CLI (logoscore call amm_module …).

See the Logos developer guide for the module framework.

What it does

The impl class AmmModuleImpl (src/amm_module_impl.{h,cpp}) is a transport adapter: the AMM domain math lives in the Rust amm_ffi crate (a transport-independent JSON FFI), and this module sequences those pure ops with chain I/O delegated to the logos_execution_zone wallet module. Its public methods (the module API is generated from the header) are:

Reads

  • resolvePoolAccount(defAHex, defBHex) — derives the pool PDA and reads/decodes the pool account (reserves in canonical a/b order, fee tier). On success { status: "ok", error: "", poolId, defAHex, defBHex, vaultAId, vaultBId, lpDefinitionId, reserveA, reserveB, liquiditySupply, feeBps }; an absent / uninitialized pool or one with no liquidity is { status: "error", error: "no_pool", poolId } (other codes: no_program_bin, amm_not_initialized, bad_config).
  • configAccount() — decodes the singleton AMM config (authority + the token/oracle program ids it was initialized with).
  • feeTiers() — the AMM's supported fee tiers as raw basis points [1, 5, 30, 100].
  • tokenHoldings(walletOpen) — the connected wallet's fungible token holdings.
  • resolveTokens(request, walletOpen) — resolves an app-provided set of token ids into selector rows (definition + wallet holding per id). The app owns the id set, so there is no network envelope or process-cached wallet state here.

Quotes (read-only pricing; no submit)

  • swapExactInQuote / swapExactOutQuote — price a swap.
  • createPoolQuote / addLiquidityQuote / removeLiquidityQuote — price a liquidity op.

Submits (on-chain transactions; return { status, error, transactionId }, except the two swaps which return a bare tx hash)

  • swapExactInput / swapExactOutput (defA = token in, defB = token out).
  • createPool / addLiquidity / removeLiquidity — the create/add paths take a fresh lpHoldingId the app supplies (the module never creates wallet accounts).
  • syncReserves — permissionless keeper op refreshing stored reserves + TWAP tick.
  • createPriceObservations / createOraclePriceAccount — seed a pool's TWAP feed.
  • transferOwnership — admin-only UpdateConfig handing over the authority.

See Amount / id conventions below, and the full logoscore runbook for a worked call per method.

How it fits together

QML  ──modules().amm_module──┐        ┌── amm_ffi (Rust cdylib, JSON FFI):
CLI  ──logoscore call────────┤        │   PDA derivation, account decode,
                             ▼        │   quote/plan math, instruction encoding
                        amm_module ───┤   — transport-independent (external_libraries)
                             │        │
                             │        └── logos_execution_zone (dependency):
                             ▼            chain reads + tx submit + base58,
                                          via modules().logos_execution_zone.*

The amm_ffi crate is deliberately I/O-free — each op takes the account data it needs as JSON input and returns a JSON result. This module is the transport adapter the crate is designed to require: it fetches accounts through the wallet module (get_account_public, list_accounts), hands them to the pure Rust op, and submits the plan the op returns (send_generic_public_transaction). It reads the same shared wallet instance the UI opened (Basecamp loads core modules as singletons; standalone the LogosAPI client cache dedups the connection), so it never opens a second wallet.

The impl is deliberately Qt-free (std::string / LogosMap / LogosList / nlohmann::json), as the universal authoring model requires.

Amount / id conventions

Account ids accept base58 or hex. The *Hex args and request-map ids are normalized at each method's boundary (via logos_execution_zone.account_id_from_base58 for base58 inputs), so the wallet/runbook's base58 ids can be passed directly.

Amounts (amountIn/minOut, u128) and deadline (u64 unix-ms) are declared nlohmann::json, so each accepts either a JSON number or a decimal string:

  • small integer → pass it bare: 1000
  • big value (an amount above the JSON/int64 range, e.g. 1e18 base units for an 18-decimal token, and the unix-ms deadline, which is always large) → pass it as a quote-wrapped string: '"1000000000000000000"'

Why the split: logoscore promotes any bare number past ~2³¹ to a JSON double, which can't hold a large integer exactly. The module therefore rejects JSON floats (rather than submit a silently-rounded amount) and requires big values as strings, which are bit-exact. A quoted CLI arg ('"…"') reaches the module with the quotes folded into the value; the string branch strips that wrapper. The UI passes QString (→ QVariant → string branch) and is unaffected.

Build

Built from the repo-root flake (which provides the amm_ffi library it links):

nix build .#amm-module
# output: result/lib/amm_module_plugin.dylib (+ libamm_ffi.dylib)

Runtime configuration

Both are absolute-path env vars set on the process that hosts the module (the logoscore daemon, or Basecamp) — not on the call:

  • AMM_PROGRAM_BIN — the deployed amm.bin. Required; its ELF determines the program id and every derived PDA. Without it, resolvePoolAccount returns { status: "error", error: "no_program_bin" }.

(The token list config TOKENS_CONFIG is an app concern now — the module no longer reads it; see apps/amm/README.md.)

Headless usage with logoscore

Prerequisites

Have all of the following in place before staging the modules dir:

  1. Nix with flakes enabled (same as the rest of the repo).

  2. The runtime CLIs (from their own flakes, per the developer guide):

    nix profile install 'github:logos-co/logos-logoscore-cli'   # logoscore (daemon + client)
    nix build 'github:logos-co/logos-module#lm'                 # lm (static plugin inspector, optional)
    

    lm introspects a built plugin without running it — handy to confirm the API (lm result/lib/amm_module_plugin.dylib shows methods, signatures, deps).

  3. This module, built (produces amm_module_plugin.dylib + libamm_ffi.dylib):

    nix build .#amm-module        # from the repo root; output under result/lib/
    
  4. The wallet module it depends on, builtlogos_execution_zone is a separate repo, not part of this tree. Build the same rev this module pins as its logos_execution_zone flake input (mismatched revs = ABI/ImageID drift), producing logos_execution_zone_plugin.dylib + libwallet_ffi.dylib:

    nix build 'github:gravityblast/logos-execution-zone-module?ref=fix/generic-tx-instruction-bstr'
    # output under result/lib/ — copy it aside before building amm-module (both use ./result)
    
  5. The deployed amm.bin for AMM_PROGRAM_BIN — the exact binary running on your target sequencer (its ELF fixes the program id and every PDA). See apps/amm/README.md and the testnet runbook.

  6. A wallet at ~/.lee/wallet (wallet_config.json with sequencer_addr pointing at your sequencer, plus storage.json with your accounts), and a running sequencer with the AMM initialized and a pool holding liquidity. Every op (including resolvePoolAccount) reads on-chain through the wallet module's get_account_public, which needs the wallet open (the handle is null until open/create_new); swapExactInput additionally needs it synced (see below).

Staging the modules directory

Core modules get no .lgx from the builder (only UI modules do), so stage a modules directory by hand — one subdir per module, each with manifest.json + variant + the plugin dylib (and its sibling FFI dylib, since rpath is @loader_path). The daemon discovers modules from this layout; it does not verify the manifest hashes at load time.

modules/
  amm_module/
    amm_module_plugin.dylib
    libamm_ffi.dylib
    variant                     # one line: darwin-arm64-dev
    manifest.json
  logos_execution_zone/
    logos_execution_zone_plugin.dylib
    libwallet_ffi.dylib
    variant
    manifest.json

amm_module/manifest.json:

{
  "name": "amm_module", "type": "core", "version": "0.1.0",
  "manifestVersion": "0.2.0", "dependencies": ["logos_execution_zone"],
  "main": { "darwin-arm64-dev": "amm_module_plugin.dylib" }
}

Start the daemon with the env vars set on it, then load the dependency first, then the module:

AMM_PROGRAM_BIN=/abs/path/to/amm.bin \
logoscore -D -m ./modules --persistence-path ./data

logoscore load-module logos_execution_zone     # dependency first
logoscore load-module amm_module

Every other op reads on-chain through the wallet module's get_account_public, which fails on a null wallet handle (surfacing as an absent pool), so open the wallet first — resolvePoolAccount then works:

logoscore call logos_execution_zone open ~/.lee/wallet/wallet_config.json ~/.lee/wallet/storage.json
logoscore call amm_module resolvePoolAccount <defA_hex> <defB_hex>

swapExactInput reuses that open wallet but additionally needs it synced (nothing opens/syncs it for you headlessly). Note the amount/deadline conventions above — small amount bare, deadline (and any big amount) quoted:

logoscore call amm_module swapExactInput \
  <defA_hex> <defB_hex> <inputHolding_hex> <outputHolding_hex> \
  1000 1 '"32503680000000"'
# big amount: replace 1000 with '"1000000000000000000"'

Debugging

Set AMM_DEBUG=1 on the daemon to trace every swapExactInput step (parsed args, the assembled account list, and the raw send_generic_public_transaction reply) to the module host's stderr, which the daemon captures in its log. A failed swap returns an empty tx hash; the trace shows the reason.

Wallet sync gotcha

get_balance reads the wallet's local synced state, and swapExactInput builds transactions against it. If you reset/reinitialize the sequencer, the wallet's storage.json may keep a stale last_synced_block ahead of the new chain — transactions then reference dead state and the sequencer rejects them (reserves don't move). Reset the cursor (last_synced_block: 0, keep key_chain/labels) and re-open + sync_to_block <height> to re-sync from genesis. resolvePoolAccount is a live sequencer read, so the stale cursor doesn't affect it — but it still needs the wallet open: the read goes through the wallet's sequencer connection (not its private keys), which only exists once the wallet is opened.

Install into Basecamp

amm_module is a core module (installed into modules/, alongside the wallet module), not a UI plugin. Build its .lgx from the root flake and install with lgpm --modules-dir … (see apps/amm/README.md for the full three-package flow: logos_execution_zone + amm_module + the amm_ui UI plugin).

QtRO / byte-string note

send_generic_public_transaction takes instruction as a byte string (std::vector<uint8_t>). This module sends the plan's u32 words as their little-endian bytes and references the program by its id hex (not the raw ELF). It requires the wallet module built with the byte-string instruction param — the fork pinned as the logos_execution_zone input. See docs/amm-swap-qtro-serialization-bug.md.

Full API runbook

The method list above is a curated subset. For a complete, worked logoscore walkthrough of every amm_module API — reads, swaps, add/remove liquidity, the keeper syncReserves, oracle setup, and admin — see docs/module/amm.md.