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).
12 KiB
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 canonicala/border, 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 freshlpHoldingIdthe 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-onlyUpdateConfighanding 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.
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,resolvePoolAccountreturns{ 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:
-
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 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. Every op (includingresolvePoolAccount) 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 \
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.