Files
lez-programs/modules/amm
Ricardo Guilherme Schmidt 8c3e6fccfe feat(wallet): humanize shared wallet experience
Consolidate wallet account decoding and portfolio handling around the shared Rust IDL decoder. Simplify AMM wallet integration, remove obsolete caches and network plumbing, and cover account selection and live flows.
2026-08-21 17:38:45 -03: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.