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.
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:
resolvePool(defAHex, defBHex)— derives the pool PDAs (config/pool/vaults/current-tick) and reads the pool's on-chain reserves. Returns{ exists: false, error }when the AMM isn't configured/initialized or the pool has no liquidity.swapExactInput(defAHex, defBHex, userInputHoldingHex, userOutputHoldingHex, amountIn, minOut, deadline)— submits an on-chainSwapExactInputtransaction (defA = token in, defB = token out); returns the tx hash (or empty on failure). See Amount / id conventions below.tokenList()— reads theTOKENS_CONFIGJSON array and returns it withdefinitionId/holdingnormalized to hex.newPositionContext(request, walletOpen, refreshWalletAccounts)— the add-liquidity view state (available tokens, fee tiers, warnings) as a context map.quoteNewPosition(request, walletOpen)— prices an add-liquidity request against current on-chain state (read-only).submitNewPosition(request, quoteHash, walletOpen, freshLpId)— submits an add-liquidity transaction. When the quote needs a fresh LP holding andfreshLpIdis empty, returns{ status: "requires_fresh_lp" }without submitting: the caller (the app backend, which owns the wallet keyset) creates the account and calls again with its id. Headless callers pre-create an LP holding and pass it.
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 are hex, not base58. The *Hex args are parsed as 32-byte hex;
a base58 id (what the wallet/runbook display) fails that parse. Convert with
tokenList() (it emits hex) or logos_execution_zone.account_id_from_base58 <base58>.
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.
1e18base 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 deployedamm.bin. Required; its ELF determines the program id and every derived PDA. Without it,resolvePoolreturns{ exists: false, error: "no_program_bin" }.TOKENS_CONFIG— JSON array of{ symbol, name, definitionId, holding, decimals }consumed bytokenList().
Headless usage with logoscore
Prerequisites
Have all of the following in place before staging the modules dir:
-
Nix with flakes enabled (same as the rest of the repo).
-
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)lmintrospects a built plugin without running it — handy to confirm the API (lm result/lib/amm_module_plugin.dylibshows methods, signatures, deps). -
This module, built (produces
amm_module_plugin.dylib+libamm_ffi.dylib):nix build .#amm-module # from the repo root; output under result/lib/ -
The wallet module it depends on, built —
logos_execution_zoneis a separate repo, not part of this tree. Build the same rev this module pins as itslogos_execution_zoneflake input (mismatched revs = ABI/ImageID drift), producinglogos_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) -
The deployed
amm.binforAMM_PROGRAM_BIN— the exact binary running on your target sequencer (its ELF fixes the program id and every PDA). Seeapps/amm/README.mdand the testnet runbook. -
A tokens config for
TOKENS_CONFIG— a JSON array of{ symbol, name, definitionId, holding, decimals }(e.g. the repo'samm-tokens.json). -
A wallet at
~/.lee/wallet(wallet_config.jsonwithsequencer_addrpointing at your sequencer, plusstorage.jsonwith your accounts), and a running sequencer with the AMM initialized and a pool holding liquidity.tokenListreadsTOKENS_CONFIGfrom disk and needs no wallet, but every other op (includingresolvePool) reads on-chain through the wallet module'sget_account_public, which needs the wallet open (the handle is null untilopen/create_new);swapExactInputadditionally 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 \
TOKENS_CONFIG=/abs/path/to/amm-tokens.json \
logoscore -D -m ./modules --persistence-path ./data
logoscore load-module logos_execution_zone # dependency first
logoscore load-module amm_module
tokenList reads TOKENS_CONFIG from disk — no wallet needed:
logoscore call amm_module tokenList
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 — resolvePool then works:
logoscore call logos_execution_zone open ~/.lee/wallet/wallet_config.json ~/.lee/wallet/storage.json
logoscore call amm_module resolvePool <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. resolvePool 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.
Known follow-ups
swapExactOutputis not exposed yet. The on-chain program supports it (amm_core::Instruction::SwapExactOutput, identical account layout toSwapExactInput), but the client path was only ever built for exact-input:amm_ffihas no exact-output op and neither the UI nor this module has aswapExactOutputmethod. Adding it is a near-copy of the exact-input path — anamm_swap_exact_output_*op in the crate plus aswapExactOutputmethod here.