feat(amm): move all AMM logic into the amm_module core module; flip UI to consume it

Introduce modules/amm — the AMM business logic as a universal core Logos module,
consumed identically by the QML UI (via modules().amm_module) and headlessly
(logoscore call amm_module ...). The module is a thin transport adapter: the
domain math lives in the Rust amm_client crate (the transport-independent JSON
FFI), and the module sequences those pure ops with chain I/O delegated to the
logos_execution_zone wallet module. It reaches 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 module owns the full AMM surface — not just swaps:
- resolvePool / swapExactInput / tokenList (the swap path)
- newPositionContext / quoteNewPosition / submitNewPosition (add-liquidity)

apps/amm: delete the app-side orchestration (SwapRuntime, NewPositionRuntime,
AmmClient/BundledAmmClient) and the amm_client link. AmmUiBackend now owns only
wallet-session lifecycle and forwards every AMM slot to modules().amm_module.
The one wallet-keyset mutation add-liquidity needs — creating a fresh LP holding
— stays in the backend (via its wallet provider, keeping the account model and
on-disk storage coherent): the module returns "requires_fresh_lp" without
submitting, the backend creates the account and resubmits with its id.

flake.nix / CMakeLists / metadata: the module links the amm_client crate; the UI
links no external lib and depends on amm_module (injected into the UI builder's
flakeInputs so the dependency resolves).

- amounts/deadline declared nlohmann::json so the generated dispatch accepts a
  JSON number (bare small ints on the CLI) or a string (exact u128 from the UI,
  or a quote-wrapped big value on the CLI); JSON floats are rejected rather than
  submit a silently-rounded amount.
- AMM_DEBUG-gated tracing for the swap path.
- Drop tests/cpp/NewPositionRuntimeTest.cpp with the class it covered
  (module-level tests to follow).
- modules/amm/README.md: architecture, headless prerequisites, logoscore recipe.
This commit is contained in:
r4bbit
2026-08-03 23:01:53 +02:00
parent 737b2f674a
commit 72b330122d
21 changed files with 1481 additions and 1319 deletions
+31
View File
@@ -0,0 +1,31 @@
cmake_minimum_required(VERSION 3.14)
project(AmmModulePlugin LANGUAGES CXX)
# Include the Logos Module CMake helper (provided by logos-module-builder).
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")
include(cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
# Derive the module name from metadata.json — single source of truth.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/metadata.json" METADATA_JSON)
string(JSON MODULE_NAME GET ${METADATA_JSON} name)
# Universal core module: we write only the impl class; the Qt plugin glue is
# generated from src/amm_module_impl.h (because metadata.json sets
# "interface": "universal"). EXTERNAL_LIBS links amm_client — the pure-Rust,
# transport-independent AMM brain (PDA/decode/quote/plan/encode) exposed as a
# JSON FFI (amm_client.h), the same library the UI links. The chain I/O
# dependency (logos_execution_zone) is declared in metadata.json and reached via
# modules().logos_execution_zone in the impl, so it is NOT listed here.
logos_module(
NAME ${MODULE_NAME}
SOURCES
src/amm_module_impl.h
src/amm_module_impl.cpp
EXTERNAL_LIBS
amm_client
)
+273
View File
@@ -0,0 +1,273 @@
# 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](https://github.com/logos-co/logos-tutorial/blob/master/logos-developer-guide.md)
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_client` 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-chain `SwapExactInput` transaction (defA = token in,
defB = token out); returns the tx hash (or empty on failure). See
**Amount / id conventions** below.
- `tokenList()` — reads the `TOKENS_CONFIG` JSON array and returns it with
`definitionId`/`holding` normalized to hex.
- `newPositionContext(request, walletOpen, refreshWalletAccounts)` — the
add-liquidity view state (available tokens, fee tiers, warnings) as a
`new-position.v1` 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 and
`freshLpId` is 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_client (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_client` 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. `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_client` library it
links):
```bash
nix build .#amm-module
# output: result/lib/amm_module_plugin.dylib (+ libamm_client.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, `resolvePool` returns
`{ exists: false, error: "no_program_bin" }`.
- `TOKENS_CONFIG` — JSON array of `{ symbol, name, definitionId, holding, decimals }`
consumed by `tokenList()`.
## 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):
```bash
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_client.dylib`):
```bash
nix build .#amm-module # from the repo root; output under result/lib/
```
4. **The wallet module it depends on, built** — `logos_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`:
```bash
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 tokens config** for `TOKENS_CONFIG` — a JSON array of
`{ symbol, name, definitionId, holding, decimals }` (e.g. the repo's
`amm-tokens.json`).
7. **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.
`tokenList` reads `TOKENS_CONFIG` from disk and needs no wallet, but every
other op (including `resolvePool`) 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_client.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`:
```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:
```bash
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:
```bash
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:
```bash
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:
```bash
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
- **`swapExactOutput` is not exposed yet.** The on-chain program supports it
(`amm_core::Instruction::SwapExactOutput`, identical account layout to
`SwapExactInput`), but the client path was only ever built for exact-input:
`amm_client` has no exact-output op and neither the UI nor this module has a
`swapExactOutput` method. Adding it is a near-copy of the exact-input path — an
`amm_swap_exact_output_*` op in the crate plus a `swapExactOutput` method here.
+29
View File
@@ -0,0 +1,29 @@
{
description = "Logos AMM core module headless AMM business logic (pool resolution + swaps)";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
# Core wallet module dependency. The input name must match the
# metadata.json `dependencies` entry so the builder resolves it as a module
# dependency. Same fork the repo-root flake pins (the QtRO byte-string
# `instruction` fix for send_generic_public_transaction).
logos_execution_zone.url = "github:gravityblast/logos-execution-zone-module?ref=fix/generic-tx-instruction-bstr";
};
# NOTE: like apps/amm, this flake is NOT built standalone. The amm_client
# crate this module links (the Rust JSON-FFI brain) lives in the repo-root
# flake, and referencing it from here would require a hardcoded `git+file://`
# path or a `path:../..` input — the latter fails flake evaluation because this
# dir is copied into the Nix store as its own flake root, so `../..` can't
# escape it. Instead, the repo-root flake.nix builds this module directly
# (src = ./modules/amm) and resolves amm_client via `self`. Build it from
# the repo root:
# nix build .#amm-module
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "amm_module",
"version": "0.1.0",
"type": "core",
"interface": "universal",
"category": "amm",
"description": "AMM business logic — on-chain pool resolution and swaps",
"main": "amm_module_plugin",
"dependencies": ["logos_execution_zone"],
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [
{ "name": "amm_client" }
],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": [],
"extra_link_libraries": []
}
}
}
+802
View File
@@ -0,0 +1,802 @@
#include "amm_module_impl.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
// Generated at build time by logos-cpp-generator. Defines `LogosModules` with
// one std-typed accessor per metadata.json dependency — here
// `logos_execution_zone`. Included only in the .cpp so the impl header the
// generator parses stays free of Qt and codegen types.
#include "logos_sdk.h"
extern "C" {
#include "amm_client.h"
}
namespace {
using json = nlohmann::json;
// AMM_DEBUG-gated tracing. The module runs in its own logos_host process whose
// stderr the daemon captures, so these lines surface in the daemon log.
bool ammDebug() {
static const bool on = std::getenv("AMM_DEBUG") != nullptr;
return on;
}
#define AMM_TRACE(msg) \
do { \
if (ammDebug()) std::cerr << "[amm-debug] " << msg << std::endl; \
} while (0)
// Absolute path to the deployed AMM program's compiled binary (amm.bin). The
// module can't derive this itself: the wallet module's bundled AMM program may
// differ from whatever is deployed on the target sequencer, and the bytes are
// what determine the program id (and every PDA derived from it).
constexpr char AMM_PROGRAM_BIN_ENV[] = "AMM_PROGRAM_BIN";
// Absolute path to the JSON token-list config consumed by tokenList().
constexpr char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG";
// new-position.v1 response schema tag (matches the app-side NewPositionRuntime
// and the Rust client's NEW_POSITION_SCHEMA).
constexpr char SCHEMA[] = "new-position.v1";
int hexVal(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
// True when `s` is exactly `len` hex digits (case-insensitive).
bool isHexLen(const std::string& s, size_t len) {
if (s.size() != len) return false;
for (const char c : s)
if (hexVal(c) < 0) return false;
return true;
}
// True when `s` is an even-length run of hex digits (case-insensitive).
bool isHexEven(const std::string& s) {
if (s.size() % 2 != 0) return false;
for (const char c : s)
if (hexVal(c) < 0) return false;
return true;
}
std::string toHex(const uint8_t* p, size_t n) {
static const char* const kDigits = "0123456789abcdef";
std::string s;
s.reserve(n * 2);
for (size_t i = 0; i < n; ++i) {
s.push_back(kDigits[p[i] >> 4]);
s.push_back(kDigits[p[i] & 0x0f]);
}
return s;
}
// Exception-safe field accessor over a json object: returns "" when the key is
// missing or not a string (nlohmann's value()/get() throw on a type mismatch).
std::string jStr(const json& obj, const char* key) {
const auto it = obj.find(key);
return (it != obj.end() && it->is_string()) ? it->get<std::string>() : std::string();
}
// Milliseconds since the unix epoch (u64). Used for the plan's `nowMs` and the
// client deadline check — the module runs on the host, not in the zkVM, so wall
// clock is available (unlike a guest).
uint64_t nowMs() {
return static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count());
}
// Decimal string -> u64. False (leaving `out` unset) on empty, non-digit, or
// overflow.
bool parseU64(const std::string& s, uint64_t& out) {
if (s.empty()) return false;
uint64_t value = 0;
for (const char c : s) {
if (c < '0' || c > '9') return false;
const uint64_t d = static_cast<uint64_t>(c - '0');
if (value > (~static_cast<uint64_t>(0) - d) / 10) return false;
value = value * 10 + d;
}
out = value;
return true;
}
// Coerce a swap amount arg — arriving as EITHER a JSON number (bare `1000` on
// the CLI) or a decimal string ("1000", a big u128 the UI passes, or a
// quote-wrapped big value on the CLI) — to its canonical decimal-string form.
// Small values fit a JSON integer; big values (amounts above the JSON/int64
// range, and unix-ms deadlines) must be strings. Returns false for JSON floats,
// negatives, or non-numeric strings, leaving `out` unset.
bool jsonAmountToDecimal(const json& j, std::string& out) {
if (j.is_string()) {
std::string s = j.get<std::string>();
// Trim whitespace, then a single pair of wrapping double quotes if
// present. This is what lets an EXACT u128 above 2^53 be passed from the
// logoscore CLI: such a value can't be a JSON number (a bare number that
// large is a lossy double), so it must be a string — but the CLI folds a
// quoted arg's quotes INTO the value (`"1e18"` arrives as the literal
// chars "\"1e18\""). Stripping the wrapper recovers the clean digits. A
// clean string from the UI (no wrapping quotes) is unaffected.
auto trim = [](std::string& x) {
const size_t a = x.find_first_not_of(" \t\n\r");
const size_t b = x.find_last_not_of(" \t\n\r");
x = (a == std::string::npos) ? std::string() : x.substr(a, b - a + 1);
};
trim(s);
if (s.size() >= 2 && s.front() == '"' && s.back() == '"') {
s = s.substr(1, s.size() - 2);
trim(s);
}
out = s;
return true;
}
if (j.is_number_unsigned()) {
out = std::to_string(j.get<uint64_t>());
return true;
}
if (j.is_number_integer()) {
const int64_t v = j.get<int64_t>();
if (v < 0) return false;
out = std::to_string(v);
return true;
}
// Reject JSON floats (and null/object/array). logoscore promotes any bare
// number beyond ~2^31 to a double, so a large bare value arrives here as a
// float — already rounded upstream. Rather than silently accept it, require
// such big numbers as a (quote-wrapped) STRING, which is bit-exact.
return false;
}
// json string/bool arrays -> std vectors, and a u32-word array -> the
// little-endian bytes the wallet module's byte-string `instruction` expects.
std::vector<std::string> jsonStrVec(const json& arr) {
std::vector<std::string> out;
if (!arr.is_array()) return out;
out.reserve(arr.size());
for (const auto& v : arr)
if (v.is_string()) out.push_back(v.get<std::string>());
return out;
}
std::vector<bool> jsonBoolVec(const json& arr) {
std::vector<bool> out;
if (!arr.is_array()) return out;
out.reserve(arr.size());
for (const auto& v : arr)
out.push_back(v.is_boolean() && v.get<bool>());
return out;
}
std::vector<uint8_t> jsonWordsToLeBytes(const json& arr) {
std::vector<uint8_t> out;
if (!arr.is_array()) return out;
out.reserve(arr.size() * sizeof(uint32_t));
for (const auto& v : arr) {
const uint32_t word = v.is_number() ? static_cast<uint32_t>(v.get<uint64_t>()) : 0;
out.push_back(static_cast<uint8_t>(word & 0xff));
out.push_back(static_cast<uint8_t>((word >> 8) & 0xff));
out.push_back(static_cast<uint8_t>((word >> 16) & 0xff));
out.push_back(static_cast<uint8_t>((word >> 24) & 0xff));
}
return out;
}
// Result of an amm_client JSON op: the `{ ok, value }` envelope decoded.
struct FfiResult {
bool ok = false;
json value;
};
// Serialize `request`, hand it to an amm_client op, and decode its
// `{ ok, value, error }` envelope. Mirrors apps/amm/src/AmmClient.cpp. `value`
// is only populated (and `ok` true) when the op reports success with an object.
FfiResult call(char* (*op)(const char*), const json& request) {
const std::string payload = request.dump();
char* raw = op(payload.c_str());
if (raw == nullptr) {
AMM_TRACE("amm_client op returned null");
return {};
}
const std::string response(raw);
amm_free(raw);
const auto doc = json::parse(response, nullptr, /*allow_exceptions=*/false);
if (!doc.is_object()) {
AMM_TRACE("amm_client op returned invalid JSON");
return {};
}
if (!doc.value("ok", false)) {
AMM_TRACE("amm_client op failure: " << doc.value("error", std::string()));
return {};
}
const auto it = doc.find("value");
if (it == doc.end() || !it->is_object()) {
AMM_TRACE("amm_client op value is not an object");
return {};
}
return {true, *it};
}
// new-position.v1 envelope builders (ported from NewPositionRuntime).
json issue(const std::string& code, const json& blockingFields = json::array()) {
return {
{"code", code},
{"recoverable", true},
{"blockingFields", blockingFields},
{"details", json::object()},
};
}
json publicError(const std::string& code,
const json& blockingFields = json::array(),
const json& details = json::object()) {
json error = issue(code, blockingFields);
error["details"] = details;
return {
{"schema", SCHEMA},
{"status", "error"},
{"canSubmit", false},
{"code", code},
{"errors", json::array({error})},
{"warnings", json::array()},
{"accountPreview", json::array()},
};
}
json contextState(const std::string& status,
const std::string& network_id,
const std::string& network_fingerprint,
const std::string& code = {}) {
json state = {
{"schema", SCHEMA},
{"status", status},
{"networkId", network_id},
{"networkFingerprint", network_fingerprint},
{"tokens", json::array()},
{"feeTiers", json::array()},
{"warnings", json::array()},
};
if (!code.empty()) state["code"] = code;
return state;
}
// A json array of the strings at `obj[key]` (empty array when absent/wrong type).
json stringArray(const json& obj, const char* key) {
const auto it = obj.find(key);
if (it == obj.end() || !it->is_array()) return json::array();
json out = json::array();
for (const auto& v : *it)
if (v.is_string()) out.push_back(v);
return out;
}
} // namespace
std::vector<uint8_t> AmmModuleImpl::loadAmmElf() {
const char* path = std::getenv(AMM_PROGRAM_BIN_ENV);
if (path == nullptr || *path == '\0') return {};
std::ifstream file(path, std::ios::binary);
if (!file) return {};
return std::vector<uint8_t>((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
std::string AmmModuleImpl::ammProgramId() {
const std::vector<uint8_t> elf = loadAmmElf();
if (elf.empty()) return {};
// Hand the deployed binary to the amm_client program_id op, which decodes it
// and computes the Image ID — 64-char lowercase hex, little-endian per u32
// word (matches `spel program-id` and the on-chain *_program_id fields).
const FfiResult r = call(amm_program_id, json{{"elf", toHex(elf.data(), elf.size())}});
if (!r.ok) {
AMM_TRACE("ammProgramId: amm_program_id op failed");
return {};
}
return jStr(r.value, "programId");
}
AmmModuleImpl::Network AmmModuleImpl::network() {
// AMM_PROGRAM_BIN / TOKENS_CONFIG are fixed for the process lifetime and this
// runs on the hot reply path, so resolve the program id + token ids once.
if (!networkResolved) {
const std::string id = ammProgramId();
if (id.empty()) {
// Not resolvable yet (AMM_PROGRAM_BIN unset/unreadable). Don't cache a
// transient miss — a later call retries.
Network net;
net.status = "config_missing";
return net;
}
programId = id;
tokenIds.clear();
for (const auto& token : tokenList()) {
const std::string token_id = jStr(token, "definitionId");
if (!token_id.empty()) tokenIds.push_back(token_id);
}
networkResolved = true;
}
Network net;
net.amm_program_id = programId;
// The program id changes per deployment, so it doubles as the network
// fingerprint (a quote can't be replayed against a different program).
net.fingerprint = programId;
net.token_ids = tokenIds;
net.status = "ready";
return net;
}
std::string AmmModuleImpl::normalizeAccountId(const std::string& id) {
size_t start = 0;
size_t end = id.size();
while (start < end && std::isspace(static_cast<unsigned char>(id[start]))) ++start;
while (end > start && std::isspace(static_cast<unsigned char>(id[end - 1]))) --end;
std::string t = id.substr(start, end - start);
if (isHexLen(t, 64)) {
std::transform(t.begin(), t.end(), t.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return t;
}
// Try base58 -> hex via the wallet module ("" on failure).
std::string hex = modules().logos_execution_zone.account_id_from_base58(t);
std::transform(hex.begin(), hex.end(), hex.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return hex;
}
nlohmann::json AmmModuleImpl::readPublicAccount(const std::string& account_id) {
// Matches the app-side accountReadJson: WalletAccountRead defaults status to
// "read_failed" and only flips to "ok" when every field is well-formed hex;
// the `account` key is present only for an ok read.
json result = {{"id", account_id}, {"status", "read_failed"}};
const std::string account_json =
modules().logos_execution_zone.get_account_public(account_id);
const auto obj = json::parse(account_json, nullptr, /*allow_exceptions=*/false);
if (!obj.is_object()) return result;
const std::string owner = jStr(obj, "program_owner");
const std::string balance = jStr(obj, "balance");
const std::string nonce = jStr(obj, "nonce");
const std::string data = jStr(obj, "data");
if (!isHexLen(owner, 64) || !isHexLen(balance, 32) || !isHexLen(nonce, 32)
|| !isHexEven(data)) {
return result;
}
result["status"] = "ok";
result["account"] = {
{"program_owner", owner},
{"balance", balance},
{"nonce", nonce},
{"data", data},
};
return result;
}
nlohmann::json AmmModuleImpl::walletAccountReads(bool wallet_open, bool refresh) {
if (!wallet_open) {
walletAccounts = json(); // invalidate — nothing to read while closed
return json::array();
}
// Each readPublicAccount is a live sequencer round-trip, so serve the cached
// set unless the caller forces a reload (submit / explicit UI refresh).
if (!refresh && !walletAccounts.is_null())
return walletAccounts;
// Normalize the wallet module's [any] return (a vector or a json array)
// through json so we can iterate/type-check it uniformly.
json accounts = modules().logos_execution_zone.list_accounts();
if (!accounts.is_array())
return json::array(); // transient — don't cache
json out = json::array();
for (const auto& entry : accounts) {
if (!entry.is_object()) continue;
// The AMM path uses only public accounts (LP holdings / token balances).
if (!entry.value("is_public", true)) continue;
const std::string id = jStr(entry, "account_id");
if (id.empty()) continue;
out.push_back(readPublicAccount(id));
}
walletAccounts = out;
return out;
}
nlohmann::json AmmModuleImpl::readConfig(const Network& net) {
const FfiResult configResult =
call(amm_config_id, json{{"ammProgramId", net.amm_program_id}});
if (!configResult.ok) return json(); // null: config_id op failed
return readPublicAccount(jStr(configResult.value, "configId"));
}
LogosMap AmmModuleImpl::resolvePool(const std::string& def_a_hex,
const std::string& def_b_hex) {
// A hard failure carries a stable `error` code (no_program_bin /
// amm_not_initialized / bad_config) so SwapCard can surface it via poolError.
// `no_pool` is the ordinary "no pool / no liquidity yet" state, which SwapCard
// treats as its normal empty state (SwapCard.qml `error !== "no_pool"`). The
// underlying FFI error string is AMM_TRACE'd to the daemon log.
auto failed = [](const std::string& error) {
return LogosMap{{"exists", false}, {"error", error}};
};
const Network net = network();
if (net.status != "ready")
// config_missing == no program id from AMM_PROGRAM_BIN (unset/unreadable/bad).
return failed("no_program_bin");
const json config = readConfig(net);
if (config.is_null())
return failed("bad_config"); // amm_config_id op failed (malformed program id)
const FfiResult pairResult = call(amm_swap_pair, json{
{"ammProgramId", net.amm_program_id},
{"tokenInId", def_a_hex},
{"tokenOutId", def_b_hex},
{"config", config},
});
if (!pairResult.ok)
return failed("bad_config"); // FFI op failed (e.g. bad token-id hex)
if (jStr(pairResult.value, "status") != "ok") {
// config_unavailable (undecodable config) surfaces as amm_not_initialized;
// same_token_pair is passed through as-is.
const std::string code = jStr(pairResult.value, "code");
if (code == "config_unavailable")
return failed("amm_not_initialized");
return failed(code.empty() ? "bad_config" : code);
}
const json pool = readPublicAccount(jStr(pairResult.value, "poolId"));
const FfiResult resolveResult = call(amm_resolve_pool, json{{"pool", pool}});
if (!resolveResult.ok)
return failed("bad_config"); // amm_resolve_pool op failed
// resolve_pool returns { exists:false } (no error) for a missing pool / no
// liquidity; re-tag it "no_pool" — the code SwapCard expects for that state.
const json resolved = resolveResult.value;
if (!resolved.value("exists", false))
return failed("no_pool");
return resolved; // { exists:true, reserveA, reserveB, feeBps }
}
std::string AmmModuleImpl::swapExactInput(const std::string& def_a_hex,
const std::string& def_b_hex,
const std::string& user_input_holding_hex,
const std::string& user_output_holding_hex,
const nlohmann::json& amount_in,
const nlohmann::json& min_out,
const nlohmann::json& deadline) {
std::string amount_in_decimal;
std::string min_out_decimal;
std::string deadline_decimal;
if (!jsonAmountToDecimal(amount_in, amount_in_decimal)
|| !jsonAmountToDecimal(min_out, min_out_decimal)
|| !jsonAmountToDecimal(deadline, deadline_decimal)) {
AMM_TRACE("swapExactInput: FAIL amount/deadline not a number or decimal string");
return {};
}
const Network net = network();
if (net.status != "ready") {
AMM_TRACE("swapExactInput: FAIL network not ready (" << net.status << ")");
return {};
}
const json config = readConfig(net);
if (config.is_null()) {
AMM_TRACE("swapExactInput: FAIL config_id op failed");
return {};
}
// amm_swap_plan resolves the pool, reorders holdings to the pool's canonical
// def order, encodes SwapExactInput, and returns a ready-to-submit plan.
const FfiResult planResult = call(amm_swap_plan, json{
{"ammProgramId", net.amm_program_id},
{"tokenInId", def_a_hex},
{"tokenOutId", def_b_hex},
{"config", config},
{"userInputHoldingId", user_input_holding_hex},
{"userOutputHoldingId", user_output_holding_hex},
{"amountIn", amount_in_decimal},
{"minOut", min_out_decimal},
{"deadlineMs", deadline_decimal},
});
if (!planResult.ok || jStr(planResult.value, "status") != "ready") {
AMM_TRACE("swapExactInput: FAIL amm_swap_plan not ready");
return {};
}
const json plan = planResult.value;
const std::vector<std::string> accounts = jsonStrVec(plan.value("accountIds", json::array()));
const std::vector<bool> signers = jsonBoolVec(plan.value("signingRequirements", json::array()));
const std::vector<uint8_t> instruction = jsonWordsToLeBytes(plan.value("instruction", json::array()));
const std::string program_id = jStr(plan, "programId");
AMM_TRACE("swapExactInput: SUBMIT programId=" << program_id
<< " instrBytes=" << instruction.size() << " accounts=" << accounts.size());
const std::string reply = modules().logos_execution_zone.send_generic_public_transaction(
accounts, signers, instruction, program_id);
AMM_TRACE("swapExactInput: tx reply=" << reply);
const auto obj = json::parse(reply, nullptr, /*allow_exceptions=*/false);
if (!obj.is_object() || !obj.value("success", false)) {
AMM_TRACE("swapExactInput: FAIL tx not successful");
return {};
}
return jStr(obj, "tx_hash");
}
LogosList AmmModuleImpl::tokenList() {
LogosList out = LogosList::array();
const char* path = std::getenv(TOKENS_CONFIG_ENV);
if (path == nullptr || *path == '\0') return out;
std::ifstream file(path);
if (!file) return out;
const std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
const auto arr = json::parse(content, nullptr, /*allow_exceptions=*/false);
if (!arr.is_array()) return out;
for (const auto& entry : arr) {
if (!entry.is_object()) continue;
// definitionId/holding may be base58 or hex — normalize both to
// lowercase hex so downstream consumers can assume hex.
const std::string definition_id = normalizeAccountId(jStr(entry, "definitionId"));
const std::string holding = normalizeAccountId(jStr(entry, "holding"));
if (definition_id.empty() || holding.empty()) continue;
// decimals must be a non-negative integer. A present-but-non-integer
// value (e.g. "decimals": "18") would make value<int>() throw
// type_error.302; that exception becomes dispatch_failed and the Qt
// caller gets an EMPTY list — one malformed entry dropping every token.
// Validate and skip just this entry (a wrong decimals would misrender
// amounts, so fail closed) instead.
const auto decimals = entry.find("decimals");
if (decimals == entry.end() || !decimals->is_number_unsigned()) continue;
json token;
token["symbol"] = jStr(entry, "symbol");
token["name"] = jStr(entry, "name");
token["definitionId"] = definition_id;
token["holding"] = holding;
token["decimals"] = decimals->get<int>();
out.push_back(token);
}
return out;
}
nlohmann::json AmmModuleImpl::buildQuoteInput(const LogosMap& request,
const Network& net,
bool wallet_open,
bool fresh_wallet_accounts,
nlohmann::json* error) {
if (net.status != "ready") {
*error = publicError(net.status);
return json();
}
const FfiResult configResult =
call(amm_config_id, json{{"ammProgramId", net.amm_program_id}});
if (!configResult.ok) {
*error = publicError("backend_error");
return json();
}
const json config = readPublicAccount(jStr(configResult.value, "configId"));
const FfiResult pairResult = call(amm_pair_ids, json{
{"ammProgramId", net.amm_program_id},
{"config", config},
{"tokenAId", request.value("tokenAId", json())},
{"tokenBId", request.value("tokenBId", json())},
});
if (!pairResult.ok) {
*error = publicError("backend_error");
return json();
}
const json pairManifest = pairResult.value;
if (jStr(pairManifest, "status") != "ok") {
*error = publicError(jStr(pairManifest, "code"));
return json();
}
const json walletAccounts = walletAccountReads(wallet_open, fresh_wallet_accounts);
const json snapshot = {
{"config", config},
{"tokenA", readPublicAccount(jStr(pairManifest, "tokenAId"))},
{"tokenB", readPublicAccount(jStr(pairManifest, "tokenBId"))},
{"pool", readPublicAccount(jStr(pairManifest, "poolId"))},
{"vaultA", readPublicAccount(jStr(pairManifest, "vaultAId"))},
{"vaultB", readPublicAccount(jStr(pairManifest, "vaultBId"))},
{"lpDefinition", readPublicAccount(jStr(pairManifest, "lpDefinitionId"))},
{"lpLockHolding", readPublicAccount(jStr(pairManifest, "lpLockHoldingId"))},
{"currentTick", readPublicAccount(jStr(pairManifest, "currentTickId"))},
{"clock", readPublicAccount(jStr(pairManifest, "clockId"))},
{"walletAvailable", wallet_open},
{"walletAccounts", walletAccounts},
};
return {
{"networkId", net.id},
{"networkFingerprint", net.fingerprint},
{"ammProgramId", net.amm_program_id},
{"request", request},
{"snapshot", snapshot},
};
}
LogosMap AmmModuleImpl::newPositionContext(const LogosMap& request,
bool wallet_open,
bool refresh_wallet_accounts) {
const Network net = network();
if (net.status != "ready")
return contextState(net.status, net.id, net.fingerprint);
const json walletAccounts = walletAccountReads(wallet_open, refresh_wallet_accounts);
const FfiResult configResult =
call(amm_config_id, json{{"ammProgramId", net.amm_program_id}});
if (!configResult.ok)
return contextState("error", net.id, net.fingerprint, "backend_error");
const json config = readPublicAccount(jStr(configResult.value, "configId"));
json configured = json::array();
for (const auto& id : net.token_ids) configured.push_back(id);
const json recent = stringArray(request, "recentTokenIds");
const json resolved = stringArray(request, "resolvedTokenIds");
const FfiResult tokenResult = call(amm_token_ids, json{
{"ammProgramId", net.amm_program_id},
{"config", config},
{"walletAccounts", walletAccounts},
{"configuredTokenIds", configured},
{"recentTokenIds", recent},
{"resolvedTokenIds", resolved},
});
const json tokenManifest = tokenResult.value;
if (!tokenResult.ok || jStr(tokenManifest, "status") != "ok") {
const std::string code =
tokenResult.ok ? jStr(tokenManifest, "code") : std::string("backend_error");
return contextState("error", net.id, net.fingerprint,
code.empty() ? "backend_error" : code);
}
json definitions = json::array();
for (const auto& id : tokenManifest.value("tokenIds", json::array()))
if (id.is_string()) definitions.push_back(readPublicAccount(id.get<std::string>()));
const FfiResult contextResult = call(amm_context, json{
{"networkId", net.id},
{"networkFingerprint", net.fingerprint},
{"ammProgramId", net.amm_program_id},
{"walletAvailable", wallet_open},
{"config", config},
{"walletAccounts", walletAccounts},
{"tokenDefinitions", definitions},
{"configuredTokenIds", configured},
{"recentTokenIds", recent},
{"resolvedTokenIds", resolved},
});
return contextResult.ok
? contextResult.value
: contextState("error", net.id, net.fingerprint, "backend_error");
}
LogosMap AmmModuleImpl::quoteNewPosition(const LogosMap& request, bool wallet_open) {
const Network net = network();
json error;
const json input = buildQuoteInput(request, net, wallet_open, /*fresh=*/false, &error);
if (!error.is_null()) return error;
const FfiResult result = call(amm_quote, input);
return result.ok ? result.value : publicError("backend_error");
}
LogosMap AmmModuleImpl::submitNewPosition(const LogosMap& request,
const std::string& quote_hash,
bool wallet_open,
const std::string& fresh_lp_id) {
if (m_requestPending) return publicError("submit_in_progress");
if (!wallet_open) return publicError("wallet_unavailable");
m_requestPending = true;
struct Guard {
bool* flag;
~Guard() { *flag = false; }
} guard{&m_requestPending};
const Network net = network();
json error;
const json input = buildQuoteInput(request, net, wallet_open, /*fresh=*/true, &error);
if (!error.is_null()) return error;
const FfiResult quoteResult = call(amm_quote, input);
if (!quoteResult.ok) return publicError("backend_error");
const json quote = quoteResult.value;
if (jStr(quote, "quoteHash") != quote_hash) {
json result = publicError("quote_changed");
result["quote"] = quote;
return result;
}
if (!quote.value("canSubmit", false)) {
json result = publicError("quote_not_submittable");
result["quote"] = quote;
return result;
}
// Fresh LP holding: the app owns wallet-keyset mutation. If the quote needs
// one and the caller hasn't supplied it, ask for it (no submit) so the
// backend can create it through its own wallet provider and call again.
json freshLp; // null
if (quote.value("requiresFreshLp", false)) {
if (fresh_lp_id.empty()) {
return json{
{"schema", SCHEMA},
{"status", "requires_fresh_lp"},
{"quote", quote},
};
}
freshLp = readPublicAccount(fresh_lp_id);
}
json planInput = input;
planInput["quoteHash"] = quote_hash;
planInput["nowMs"] = nowMs();
if (!freshLp.is_null()) planInput["freshLp"] = freshLp;
const FfiResult planResult = call(amm_plan, planInput);
if (!planResult.ok) return publicError("backend_error");
const json plan = planResult.value;
if (jStr(plan, "status") != "ready") {
const std::string code = jStr(plan, "code");
return publicError(code.empty() ? "wallet_submission_failed" : code);
}
uint64_t deadline = 0;
if (!parseU64(jStr(plan, "deadlineMs"), deadline) || nowMs() >= deadline)
return publicError("transaction_deadline_expired");
const std::vector<std::string> accounts = jsonStrVec(plan.value("accountIds", json::array()));
const std::vector<bool> signers = jsonBoolVec(plan.value("signingRequirements", json::array()));
const std::vector<uint8_t> instruction = jsonWordsToLeBytes(plan.value("instruction", json::array()));
const std::string program_id = jStr(plan, "programId");
const std::string reply = modules().logos_execution_zone.send_generic_public_transaction(
accounts, signers, instruction, program_id);
const auto obj = json::parse(reply, nullptr, /*allow_exceptions=*/false);
if (!obj.is_object() || !obj.value("success", false))
return publicError("wallet_submission_failed");
// Native tx hash (64-char hex) -> base58 transaction id via the wallet
// module (avoids linking libbase58 just for this encode).
const std::string tx_hash = jStr(obj, "tx_hash");
const std::string transaction_id =
modules().logos_execution_zone.account_id_to_base58(tx_hash);
if (transaction_id.empty()) return publicError("wallet_submission_failed");
return {
{"schema", SCHEMA},
{"status", "submitted"},
{"transactionId", transaction_id},
{"deadlineMs", plan.value("deadlineMs", json())},
};
}
+165
View File
@@ -0,0 +1,165 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include <logos_json.h> // LogosMap / LogosList (nlohmann::json aliases)
#include <logos_module_context.h> // LogosModuleContext base + modules()
// AMM business logic as a universal core Logos module.
//
// Orchestration only: the AMM domain math (PDA derivation, on-chain account
// decoding, quote/plan computation, and instruction encoding) lives in the Rust
// `amm_client` crate and is reached through its JSON FFI (amm_client.h — one
// `char* op(const char*)` per operation, request and response both JSON). This
// module sequences those ops with chain I/O delegated to the
// `logos_execution_zone` wallet module (reached via modules().logos_execution_zone).
//
// The same surface is consumed by the QML UI (via modules().amm_module) and
// headlessly (logoscore call amm_module ...). Ported from the app-side
// SwapRuntime / NewPositionRuntime orchestration (apps/amm/src) plus the
// backend's network-context derivation, made Qt-free (std::string / LogosMap /
// LogosList / nlohmann::json) as the universal authoring model requires.
//
// Public methods ARE the module's API; the Qt plugin glue is generated from
// this header because metadata.json sets "interface": "universal". Keep the
// header Qt-free — std types only.
class AmmModuleImpl : public LogosModuleContext {
public:
AmmModuleImpl() = default;
~AmmModuleImpl() = default;
/// Derives the pool PDAs (config / pool / vaults / current-tick) for the
/// (def_a_hex, def_b_hex) pair and reads the pool's on-chain reserves.
/// On success: `{ exists:true, reserveA, reserveB, feeBps }` (reserveA/
/// reserveB in the pool's canonical def order). Otherwise
/// `{ exists:false, error:<code> }`: `no_program_bin` (AMM_PROGRAM_BIN
/// unset/unreadable/bad), `amm_not_initialized` (config undecodable),
/// `bad_config` (bad ids / internal decode failure), `same_token_pair`, or
/// `no_pool` for the ordinary "no pool / no liquidity yet" state.
LogosMap resolvePool(const std::string& def_a_hex, const std::string& def_b_hex);
/// Submits an on-chain SwapExactInput transaction against the pool for
/// (def_a_hex = token in, def_b_hex = token out). amount_in / min_out are
/// u128 base-unit amounts; deadline is a u64 unix-ms timestamp. Each accepts
/// EITHER a small JSON integer (bare `1000` on the CLI) OR a decimal string
/// (what the UI passes, and what the CLI must use for any big value — large
/// amounts and the unix-ms deadline — as a quote-wrapped arg like
/// '"1000000000000000000"'). Declared `nlohmann::json` so the generated
/// dispatch hands us the raw value; JSON floats are rejected rather than
/// submit a silently-rounded amount. Returns the tx hash, or an empty string
/// on failure (no pool, unreadable AMM_PROGRAM_BIN, bad inputs, failed tx).
std::string swapExactInput(const std::string& def_a_hex,
const std::string& def_b_hex,
const std::string& user_input_holding_hex,
const std::string& user_output_holding_hex,
const nlohmann::json& amount_in,
const nlohmann::json& min_out,
const nlohmann::json& deadline);
/// Reads the token list config at TOKENS_CONFIG (a JSON array of
/// { symbol, name, definitionId, holding, decimals }) and returns it,
/// normalizing definitionId/holding to lowercase hex. Empty list if
/// TOKENS_CONFIG is unset / unreadable / not a JSON array.
LogosList tokenList();
/// New-position (add-liquidity) view state: reads the AMM config + the
/// user's wallet accounts and returns the `new-position.v1` context map the
/// UI renders (available tokens, fee tiers, warnings). `wallet_open` gates
/// whether wallet accounts are included; `refresh_wallet_accounts` forces a
/// fresh read rather than a cached one.
LogosMap newPositionContext(const LogosMap& request,
bool wallet_open,
bool refresh_wallet_accounts);
/// Prices an add-liquidity request against current on-chain state and
/// returns the `new-position.v1` quote map (quoteHash, canSubmit,
/// requiresFreshLp, amounts, warnings). Read-only — no submission.
LogosMap quoteNewPosition(const LogosMap& request, bool wallet_open);
/// Submits an add-liquidity transaction. Re-quotes and validates `quote_hash`
/// against the current quote. If the quote requires a fresh LP holding and
/// `fresh_lp_id` is 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. Otherwise builds
/// the plan (injecting the fresh LP account when given) and submits, then
/// returns the `new-position.v1` submitted/error map.
LogosMap submitNewPosition(const LogosMap& request,
const std::string& quote_hash,
bool wallet_open,
const std::string& fresh_lp_id);
private:
// Off-chain "network" context, derived from the process env (the same
// sources the app backend used): AMM deployment id from AMM_PROGRAM_BIN,
// configured token set from TOKENS_CONFIG. `status` is "ready" once the
// program id resolves, else "config_missing".
struct Network {
std::string id = "lez";
std::string status;
std::string fingerprint; // == amm_program_id (binds a quote to the deploy)
std::string amm_program_id; // 64-char lowercase hex
std::vector<std::string> token_ids;
};
// AMM_PROGRAM_BIN / TOKENS_CONFIG are fixed for the process lifetime, and
// this runs on the hot reply path (every op), so it resolves the program id
// + token ids ONCE and caches them (networkResolved). Cached only on
// success, so a startup miss (bin not readable yet) retries.
Network network();
// 64-char lowercase-hex AMM program id via the amm_client `program_id` op
// over the AMM_PROGRAM_BIN bytes (empty if unset/unreadable/bad).
std::string ammProgramId();
// Reads AMM_PROGRAM_BIN into a byte vector (empty on unset/unreadable/empty).
std::vector<uint8_t> loadAmmElf();
// Normalizes an account id given as 64-char hex or base58 to lowercase hex
// (base58 via the wallet module). Empty string if it is neither.
std::string normalizeAccountId(const std::string& id);
// Derives the config account id (amm_config_id) and reads it, returning the
// account-read shape the amm_client ops embed. Null json when the config_id
// op itself fails (readPublicAccount always yields at least {id,status}).
nlohmann::json readConfig(const Network& net);
// Reads a public account through the wallet module and returns the
// { id, status, account:{ program_owner, balance, nonce, data } } shape the
// amm_client ops expect (see the app-side accountReadJson). `account` is
// omitted when the read has no data (uninitialized/nonexistent).
nlohmann::json readPublicAccount(const std::string& account_id);
// The user's own public account reads (empty when the wallet is closed).
// Cached across calls (walletAccounts); `refresh` reloads instead of serving
// the cache — quote reuses it, submit forces fresh — since each read is a
// live sequencer round-trip.
nlohmann::json walletAccountReads(bool wallet_open, bool refresh);
// Builds the { networkId, networkFingerprint, ammProgramId, request,
// snapshot } input shared by quoteNewPosition / submitNewPosition. On a
// recoverable precondition failure, sets *error to a new-position.v1 error
// map and returns a null json.
nlohmann::json buildQuoteInput(const LogosMap& request,
const Network& net,
bool wallet_open,
bool fresh_wallet_accounts,
nlohmann::json* error);
// Guards against a re-entrant in-flight request (e.g. a double-submit) on the
// shared module instance. Released per call, so the app's fresh-LP resubmit
// still proceeds.
bool m_requestPending = false;
// Process-lifetime network config, resolved once (see network()). Serialized
// module dispatch means no locking is needed; there is no invalidation, as
// runtime env reload is not supported.
bool networkResolved = false;
std::string programId;
std::vector<std::string> tokenIds;
// Cache of the user's public account reads for the context/quote path (each
// read is a live sequencer round-trip). Null until first read; `refresh`
// reloads it, and it's dropped when the wallet closes. See walletAccountReads.
nlohmann::json walletAccounts;
};