diff --git a/Cargo.lock b/Cargo.lock index 0147201a..33bb2f9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,38 +77,38 @@ dependencies = [ ] [[package]] -name = "amm_client" +name = "amm_core" version = "0.1.0" dependencies = [ "alloy-primitives", - "amm_core", "borsh", - "cbindgen", - "clock_core", - "hex", "lee_core", - "pretty_assertions", - "risc0-binfmt", "risc0-zkvm", + "ruint", "serde", - "serde_json", - "sha2", + "spel-framework-macros", "token_core", - "twap_oracle_core", ] [[package]] -name = "amm_core" +name = "amm_ffi" version = "0.1.0" dependencies = [ "alloy-primitives", + "amm_core", "borsh", + "cbindgen", + "clock_core", + "hex", "lee_core", + "pretty_assertions", + "risc0-binfmt", "risc0-zkvm", - "ruint", "serde", - "spel-framework-macros", + "serde_json", + "sha2", "token_core", + "twap_oracle_core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 35a1dee8..76b127fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = [ - "apps/amm/client", + "modules/amm/ffi", "programs/token/core", "programs/token", "programs/token/methods", diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 7f0121c1..5ad917a8 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -4,9 +4,6 @@ project(AmmUiPlugin LANGUAGES CXX) find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2) qt_standard_project_setup(REQUIRES 6.8) -find_package(PkgConfig REQUIRED) -pkg_check_modules(BASE58 REQUIRED IMPORTED_TARGET libbase58) - include(CTest) if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) @@ -27,6 +24,9 @@ add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/share # ui_qml module with a hand-written C++ backend (QtRO .rep view contract + # generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module. +# The AMM business logic lives in the amm_module core module (declared as a +# dependency in metadata.json, reached via modules().amm_module in the backend), +# so the UI links no amm_ffi library of its own. logos_module( NAME amm_ui REP_FILE src/AmmUiBackend.rep @@ -36,34 +36,10 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp - src/ActiveNetwork.h - src/AmmClient.h - src/AmmClient.cpp - src/NewPositionRuntime.h - src/NewPositionRuntime.cpp - src/SwapRuntime.h - src/SwapRuntime.cpp FIND_PACKAGES Qt6Gui LINK_LIBRARIES Qt6::Gui - PkgConfig::BASE58 LINK_TARGETS logos_wallet_access - EXTERNAL_LIBS - amm_client ) - -if(BUILD_TESTING) - add_executable(amm_new_position_runtime_test - tests/cpp/NewPositionRuntimeTest.cpp - src/NewPositionRuntime.cpp - ) - target_include_directories(amm_new_position_runtime_test PRIVATE src) - target_link_libraries(amm_new_position_runtime_test PRIVATE - Qt6::Core - PkgConfig::BASE58 - logos_wallet_access - ) - add_test(NAME amm_new_position_runtime COMMAND amm_new_position_runtime_test) -endif() diff --git a/apps/amm/README.md b/apps/amm/README.md index b33841a0..6c7e4f06 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -52,9 +52,9 @@ This makes `lgpm` available as a global command. ## Running the UI standalone -The app is built from the **repository-root** flake (which also provides the -`amm_client_ffi` library it links). From the repo root, launch it with its named -attribute: +The app is built from the **repository-root** flake (which also builds the +`amm_module` core module the UI delegates its AMM logic to). From the repo root, +launch it with its named attribute: ```bash nix run .#amm-ui @@ -62,9 +62,10 @@ nix run .#amm-ui This builds and runs the application in development mode. The Logos bridge is unavailable in standalone mode, but the UI layout and mock data are fully functional. -Build just the FFI crate with `nix build .#amm_client_ffi`. (Each UI is exposed -under its own name, so future apps are `nix run .#` — there is no bare -`nix run .` default.) +Build just the AMM core module with `nix build .#amm-module`, or its underlying +logic crate with `nix build .#amm_ffi`. (Each UI is exposed under its own +name, so future apps are `nix run .#` — there is no bare `nix run .` +default.) ## Running inside Logos Basecamp diff --git a/apps/amm/VALIDATION.md b/apps/amm/VALIDATION.md index 8663bfa5..89216fb4 100644 --- a/apps/amm/VALIDATION.md +++ b/apps/amm/VALIDATION.md @@ -8,8 +8,8 @@ syntax, and the complete module build. Run from the repository root: ```bash -cargo +1.94.0 test -p amm_client -cargo +1.94.0 clippy -p amm_client --all-targets -- -D warnings +cargo +1.94.0 test -p amm_ffi +cargo +1.94.0 clippy -p amm_ffi --all-targets -- -D warnings logos_qml=$(nix build github:logos-co/logos-design-system/6176f0d7a5dfeb64a7f0f98e7ca2bf71a4804772 --no-link --print-out-paths) amm_qml=$(nix build ./apps/amm#packages.x86_64-linux.default --no-link --print-out-paths) qt_qml=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtdeclarative-[0-9]') diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 6b784955..63196943 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -26,21 +26,15 @@ inputs.logos-execution-zone.url = "github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05"; }; - - amm_client.url = "path:../.."; }; - # NOTE: this flake is no longer built standalone. The amm_client crate - # (the Rust C FFI library the AmmUiBackend C++ code links against) lives in - # the repo-root flake, and referencing it from here would require either a - # hardcoded absolute `git+file://` path or a `path:../..` input — the latter - # fails flake evaluation because the app directory is copied into the Nix - # store as its own flake root, so `../..` can't escape it there. Instead, - # the repo-root flake.nix builds this module directly (src = ./apps/amm) - # and resolves amm_client via `self`. The repo-root flake exposes the UI - # as a named attribute (there is no bare `default`): run it from the repo root - # with `nix run .#amm-ui`, and build just the FFI crate with - # `nix build .#amm_client`. + # NOTE: this flake is no longer built standalone; the repo-root flake.nix + # builds the UI directly (src = ./apps/amm). The UI links no external lib of + # its own — the AMM logic lives in the amm_ffi crate, which the amm_module + # core module links; the UI reaches it via modules().amm_module (declared in + # metadata.json `dependencies`). The repo-root flake exposes the UI as a named + # attribute (there is no bare `default`): run it with `nix run .#amm-ui`, and + # build the AMM logic crate with `nix build .#amm_ffi`. outputs = inputs@{ logos-module-builder, shared_wallet, ... }: logos-module-builder.lib.mkLogosQmlModule { src = ./.; @@ -49,12 +43,7 @@ preConfigure = '' cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}") ''; - externalLibInputs = { - amm_client = { - input = inputs.amm_client; - packages.default = "amm_client"; - }; - }; + externalLibInputs = { }; postInstall = '' # The builder installs the view under lib/qml after this hook. Its # import descriptor points back to this compiled shared QML module. diff --git a/apps/amm/metadata.json b/apps/amm/metadata.json index 7b123c9c..5525cefb 100644 --- a/apps/amm/metadata.json +++ b/apps/amm/metadata.json @@ -7,20 +7,18 @@ "main": "amm_ui_plugin", "view": "qml/Main.qml", "icon": "icons/amm.png", - "dependencies": ["logos_execution_zone"], + "dependencies": ["logos_execution_zone", "amm_module"], "nix": { "packages": { "build": ["pkg-config"], "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"] }, - "external_libraries": [ - { "name": "amm_client" } - ], + "external_libraries": [], "cmake": { "find_packages": [], "extra_sources": [], - "extra_include_dirs": ["lib"], + "extra_include_dirs": [], "extra_link_libraries": [] } } diff --git a/apps/amm/src/ActiveNetwork.h b/apps/amm/src/ActiveNetwork.h deleted file mode 100644 index 771442b9..00000000 --- a/apps/amm/src/ActiveNetwork.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include -#include - -// Network context handed to the new-position flow. The AMM deployment identity -// (ammProgramId, from $AMM_PROGRAM_BIN) and the configured token set (tokenIds, -// from $TOKENS_CONFIG) are the same sources the Swap view uses; there is no -// separate network config file or channel-identity probe. `fingerprint` binds a -// quote to the deployment so a quote can't be replayed against a different one. -struct ActiveNetworkSnapshot { - QString id; - QString status; - QString fingerprint; - QString ammProgramId; - QStringList tokenIds; -}; diff --git a/apps/amm/src/AmmClient.cpp b/apps/amm/src/AmmClient.cpp deleted file mode 100644 index c78ce181..00000000 --- a/apps/amm/src/AmmClient.cpp +++ /dev/null @@ -1,91 +0,0 @@ -#include "AmmClient.h" - -#include -#include -#include - -#include "amm_client.h" - -namespace { - using Operation = char* (*)(const char*); - - AmmClientResult call(Operation operation, const QJsonObject& request) - { - const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact); - char* raw = operation(payload.constData()); - if (!raw) { - qWarning() << "AmmClient: bundled client returned a null response"; - return {}; - } - const QByteArray response(raw); - amm_free(raw); - - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(response, &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) { - qWarning() << "AmmClient: bundled client returned invalid JSON"; - return {}; - } - const QJsonObject envelope = document.object(); - if (!envelope.value(QStringLiteral("ok")).toBool()) { - qWarning() << "AmmClient: bundled client failure:" - << envelope.value(QStringLiteral("error")).toString(); - return {}; - } - if (!envelope.value(QStringLiteral("value")).isObject()) { - qWarning() << "AmmClient: bundled client value is not an object"; - return {}; - } - return { true, envelope.value(QStringLiteral("value")).toObject() }; - } -} - -AmmClientResult BundledAmmClient::configId(const QJsonObject& request) const -{ - return call(amm_config_id, request); -} - -AmmClientResult BundledAmmClient::tokenIds(const QJsonObject& request) const -{ - return call(amm_token_ids, request); -} - -AmmClientResult BundledAmmClient::pairIds(const QJsonObject& request) const -{ - return call(amm_pair_ids, request); -} - -AmmClientResult BundledAmmClient::context(const QJsonObject& request) const -{ - return call(amm_context, request); -} - -AmmClientResult BundledAmmClient::quote(const QJsonObject& request) const -{ - return call(amm_quote, request); -} - -AmmClientResult BundledAmmClient::plan(const QJsonObject& request) const -{ - return call(amm_plan, request); -} - -AmmClientResult BundledAmmClient::swapPair(const QJsonObject& request) const -{ - return call(amm_swap_pair, request); -} - -AmmClientResult BundledAmmClient::resolvePool(const QJsonObject& request) const -{ - return call(amm_resolve_pool, request); -} - -AmmClientResult BundledAmmClient::swapPlan(const QJsonObject& request) const -{ - return call(amm_swap_plan, request); -} - -AmmClientResult BundledAmmClient::programId(const QJsonObject& request) const -{ - return call(amm_program_id, request); -} diff --git a/apps/amm/src/AmmClient.h b/apps/amm/src/AmmClient.h deleted file mode 100644 index b78cc9b4..00000000 --- a/apps/amm/src/AmmClient.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include - -struct AmmClientResult { - bool ok = false; - QJsonObject value; -}; - -class AmmClient { -public: - virtual ~AmmClient() = default; - - virtual AmmClientResult configId(const QJsonObject& request) const = 0; - virtual AmmClientResult tokenIds(const QJsonObject& request) const = 0; - virtual AmmClientResult pairIds(const QJsonObject& request) const = 0; - virtual AmmClientResult context(const QJsonObject& request) const = 0; - virtual AmmClientResult quote(const QJsonObject& request) const = 0; - virtual AmmClientResult plan(const QJsonObject& request) const = 0; - virtual AmmClientResult swapPair(const QJsonObject& request) const = 0; - virtual AmmClientResult resolvePool(const QJsonObject& request) const = 0; - virtual AmmClientResult swapPlan(const QJsonObject& request) const = 0; - virtual AmmClientResult programId(const QJsonObject& request) const = 0; -}; - -class BundledAmmClient final : public AmmClient { -public: - AmmClientResult configId(const QJsonObject& request) const override; - AmmClientResult tokenIds(const QJsonObject& request) const override; - AmmClientResult pairIds(const QJsonObject& request) const override; - AmmClientResult context(const QJsonObject& request) const override; - AmmClientResult quote(const QJsonObject& request) const override; - AmmClientResult plan(const QJsonObject& request) const override; - AmmClientResult swapPair(const QJsonObject& request) const override; - AmmClientResult resolvePool(const QJsonObject& request) const override; - AmmClientResult swapPlan(const QJsonObject& request) const override; - AmmClientResult programId(const QJsonObject& request) const override; -}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 60a3836d..312949b5 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,46 +1,50 @@ #include "AmmUiBackend.h" -#include -#include - -#include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include "AmmClient.h" #include "LogosWalletProvider.h" -#include "NewPositionRuntime.h" -#include "SwapRuntime.h" #include "WalletController.h" #include "logos_api.h" #include "logos_sdk.h" namespace { - // Absolute path to the deployed AMM program's RISC Zero program binary - // (amm.bin — the `ProgramBinary` `.bin` from the docker guest build, decoded - // on the Rust side via `ProgramBinary::decode`; NOT a raw ELF — pointing at - // the raw guest ELF yields a different/failed program id). The app can't - // safely embed/derive this itself: the wallet module's bundled AMM program - // may differ from whatever is actually deployed on the target sequencer, and - // the binary's bytes are what determine its program id (and therefore every - // PDA derived from it). See apps/amm/README.md. - const char AMM_PROGRAM_BIN_ENV[] = "AMM_PROGRAM_BIN"; + const char NEW_POSITION_SCHEMA[] = "new-position.v1"; + + // The new-position context placeholder published before the module + // connection is up (matches the module's "loading" contextState). + QVariantMap loadingContext() + { + return QVariantMap { + { QStringLiteral("schema"), QString::fromLatin1(NEW_POSITION_SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("loading") }, + { QStringLiteral("networkId"), QStringLiteral("lez") }, + { QStringLiteral("networkFingerprint"), QString() }, + { QStringLiteral("tokens"), QVariantList() }, + { QStringLiteral("feeTiers"), QVariantList() }, + { QStringLiteral("warnings"), QVariantList() }, + }; + } - // Absolute path to the JSON token-list config consumed by tokenList() - // (see apps/amm/README.md). Config-driven so the Swap view's token picker - // doesn't need a hardcoded/dummy token list. - const char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; + // A new-position.v1 error envelope (matches the module's publicError), for + // the backend-side failure paths (e.g. LP-account creation failing). + QVariantMap newPositionError(const QString& code) + { + return QVariantMap { + { QStringLiteral("schema"), QString::fromLatin1(NEW_POSITION_SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("error") }, + { QStringLiteral("canSubmit"), false }, + { QStringLiteral("code"), code }, + { QStringLiteral("errors"), QVariantList { QVariantMap { + { QStringLiteral("code"), code }, + { QStringLiteral("recoverable"), true }, + { QStringLiteral("blockingFields"), QVariantList() }, + { QStringLiteral("details"), QVariantMap() }, + } } }, + { QStringLiteral("warnings"), QVariantList() }, + { QStringLiteral("accountPreview"), QVariantList() }, + }; + } } AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) @@ -49,17 +53,14 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) m_logos(std::make_unique(m_logosAPI)), m_wallet(std::make_unique(m_logosAPI)), m_walletController(std::make_unique( - *m_wallet, QStringLiteral("AmmUI"))), - m_ammClient(std::make_unique()), - m_newPosition(std::make_unique(m_wallet.get(), m_ammClient.get())), - m_swap(std::make_unique(m_wallet.get(), m_ammClient.get())) + *m_wallet, QStringLiteral("AmmUI"))) { setWalletStateReady(false); - setNewPositionContext(m_newPosition->context( - QVariantMap(), networkSnapshot(), false, false)); connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); + // Publishes an initial "loading" context (walletStateReady is still false, + // so it does not yet reach the module). syncWalletState(); m_walletController->start(); QTimer::singleShot(0, this, [this]() { @@ -107,7 +108,6 @@ void AmmUiBackend::disconnectWallet() { m_walletController->disconnect(); setWalletStateReady(true); - m_newPosition->clearWalletAccounts(); refreshNewPositionContext(QVariantMap()); } @@ -147,25 +147,45 @@ void AmmUiBackend::refreshNewPositionContext(QVariantMap request) else { request = m_newPositionHints; } - setNewPositionContext(m_newPosition->context( - request, networkSnapshot(), isWalletOpen(), refreshWalletAccounts)); + if (!walletStateReady()) { + setNewPositionContext(loadingContext()); + return; + } + setNewPositionContext(m_logos->amm_module.newPositionContext( + request, isWalletOpen(), refreshWalletAccounts)); } QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request) { - return m_newPosition->quote(request, networkSnapshot(), isWalletOpen()); + return m_logos->amm_module.quoteNewPosition(request, isWalletOpen()); } QVariantMap AmmUiBackend::submitNewPosition(QVariantMap request, QString quoteHash) { - return m_newPosition->submit( - request, quoteHash, networkSnapshot(), isWalletOpen()); + // First attempt with no LP account. If the module needs a fresh LP holding + // it returns "requires_fresh_lp" without submitting; we own wallet-keyset + // mutation, so create the account here (keeping the account model + on-disk + // storage coherent) and resubmit with its id. + QVariantMap result = m_logos->amm_module.submitNewPosition( + request, quoteHash, isWalletOpen(), QString()); + + if (result.value(QStringLiteral("status")).toString() + == QStringLiteral("requires_fresh_lp")) { + const QString lpId = m_walletController->createAccount(true); + if (lpId.isEmpty()) + return newPositionError(QStringLiteral("wallet_submission_failed")); + result = m_logos->amm_module.submitNewPosition( + request, quoteHash, isWalletOpen(), lpId); + } + + if (result.value(QStringLiteral("status")).toString() == QStringLiteral("submitted")) + refreshBalances(); + return result; } void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); - const bool walletWasOpen = isWalletOpen(); setIsWalletOpen(state.isWalletOpen); setWalletExists(state.walletExists); @@ -177,129 +197,31 @@ void AmmUiBackend::syncWalletState() setSequencerAddr(state.sequencerAddress); setSequencerReachable(state.sequencerReachable); - if (walletWasOpen && !state.isWalletOpen) - m_newPosition->clearWalletAccounts(); - publishNetworkContext(); } void AmmUiBackend::publishNetworkContext() { - setNewPositionContext(m_newPosition->context( - m_newPositionHints, networkSnapshot(), isWalletOpen(), false)); -} - -QString AmmUiBackend::ammProgramIdHex() -{ - const QByteArray elf = loadAmmElf(); - if (elf.isEmpty()) - return QString(); - // Hand the deployed program 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 AmmClientResult result = m_ammClient->programId( - QJsonObject { { QStringLiteral("elf"), QString::fromLatin1(elf.toHex()) } }); - if (!result.ok) { - qWarning() << "AmmUiBackend::ammProgramIdHex: amm_program_id failed"; - return QString(); - } - return result.value.value(QStringLiteral("programId")).toString(); -} - -ActiveNetworkSnapshot AmmUiBackend::networkSnapshot() -{ - ActiveNetworkSnapshot snapshot; - snapshot.id = QStringLiteral("lez"); - // Defer program/token resolution (which reaches the module) until wallet - // state is resolved; the constructor publishes an initial context before - // the module is up, and syncWalletState() republishes once it is. if (!walletStateReady()) { - snapshot.status = QStringLiteral("loading"); - return snapshot; - } - // Resolve the AMM deployment id ($AMM_PROGRAM_BIN) and configured token set - // ($TOKENS_CONFIG) ONCE and cache — they're fixed for the process lifetime. - // networkSnapshot() runs on the quote hot path and from inside runtime reply - // callbacks, and tokenList() makes remote base58 conversions; recomputing each - // call reenters the module connection and hangs the reply. - if (!m_networkResolved) { - m_ammProgramIdCache = ammProgramIdHex(); - m_tokenIdsCache.clear(); - // Configured token set = the TOKENS_CONFIG definition ids, the same source - // the Swap view's token picker uses (tokenList normalizes them to hex). - const QVariantList tokens = tokenList(); - for (const QVariant& entry : tokens) { - const QString id = entry.toMap().value(QStringLiteral("definitionId")).toString(); - if (!id.isEmpty()) - m_tokenIdsCache.append(id); - } - m_networkResolved = true; + setNewPositionContext(loadingContext()); + return; } - snapshot.ammProgramId = m_ammProgramIdCache; - // Bind a quote to this AMM deployment: the program id changes per deployment, - // so it doubles as the network fingerprint (a quote can't be replayed against - // a different program). Empty when AMM_PROGRAM_BIN is unset — status gates it. - snapshot.fingerprint = m_ammProgramIdCache; - snapshot.tokenIds = m_tokenIdsCache; - snapshot.status = m_ammProgramIdCache.isEmpty() - ? QStringLiteral("config_missing") - : QStringLiteral("ready"); - return snapshot; -} - -QString AmmUiBackend::normalizeAccountId(const QString& id) -{ - const QString t = id.trimmed(); - // Already 64 hex chars? - if (t.size() == 64) { - bool allHex = true; - for (const QChar c : t) { - if (!std::isxdigit(static_cast(c.toLatin1()))) { - allHex = false; - break; - } - } - if (allHex) - return t.toLower(); - } - // Try base58 -> hex via the wallet module. - const QString hex = m_logos->logos_execution_zone.account_id_from_base58(t); - return hex.toLower(); // account_id_from_base58 returns "" on failure -} - -QByteArray AmmUiBackend::loadAmmElf() -{ - const QByteArray binPath = qgetenv(AMM_PROGRAM_BIN_ENV); - if (binPath.isEmpty()) { - qWarning() << "AmmUiBackend::loadAmmElf: AMM_PROGRAM_BIN not set"; - return QByteArray(); - } - QFile elfFile(QString::fromLocal8Bit(binPath)); - if (!elfFile.open(QIODevice::ReadOnly)) { - qWarning() << "AmmUiBackend::loadAmmElf: cannot read AMM_PROGRAM_BIN at" << elfFile.fileName(); - return QByteArray(); - } - const QByteArray elf = elfFile.readAll(); - elfFile.close(); - if (elf.isEmpty()) { - qWarning() << "AmmUiBackend::loadAmmElf: AMM_PROGRAM_BIN is empty"; - return QByteArray(); - } - return elf; + setNewPositionContext(m_logos->amm_module.newPositionContext( + m_newPositionHints, isWalletOpen(), false)); } QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex) { - return m_swap->resolvePool(defAHex, defBHex, networkSnapshot()); + return m_logos->amm_module.resolvePool(defAHex, defBHex); } QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal) { - const QString txHash = m_swap->swap(defAHex, defBHex, userInputHoldingHex, userOutputHoldingHex, - amountInDecimal, minOutDecimal, deadlineDecimal, - networkSnapshot(), isWalletOpen()); + const QString txHash = m_logos->amm_module.swapExactInput( + defAHex, defBHex, userInputHoldingHex, userOutputHoldingHex, + amountInDecimal, minOutDecimal, deadlineDecimal); if (!txHash.isEmpty()) refreshBalances(); return txHash; @@ -307,59 +229,5 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u QVariantList AmmUiBackend::tokenList() { - QVariantList out; - - const QByteArray path = qgetenv(TOKENS_CONFIG_ENV); - if (path.isEmpty()) { - qWarning() << "AmmUiBackend::tokenList: TOKENS_CONFIG not set"; - return out; - } - - QFile file(QString::fromLocal8Bit(path)); - if (!file.open(QIODevice::ReadOnly)) { - qWarning() << "AmmUiBackend::tokenList: cannot read TOKENS_CONFIG at" << file.fileName(); - return out; - } - const QByteArray json = file.readAll(); - file.close(); - - QJsonParseError parseError{}; - const QJsonDocument doc = QJsonDocument::fromJson(json, &parseError); - if (parseError.error != QJsonParseError::NoError || !doc.isArray()) { - qWarning() << "AmmUiBackend::tokenList: TOKENS_CONFIG at" << file.fileName() - << "is not a valid JSON array:" << parseError.errorString(); - return out; - } - - const QJsonArray arr = doc.array(); - for (const QJsonValue& entry : arr) { - if (!entry.isObject()) { - qWarning() << "AmmUiBackend::tokenList: skipping non-object entry in TOKENS_CONFIG"; - continue; - } - const QJsonObject obj = entry.toObject(); - const QString symbol = obj.value(QStringLiteral("symbol")).toString(); - - // TOKENS_CONFIG entries may give definitionId/holding as hex or - // base58 (the wallet/runbook display base58) — normalize both to - // lowercase hex here so every downstream consumer (resolvePool, - // swapExactInput, and the QML's hex comparisons) can assume hex. - const QString definitionId = - normalizeAccountId(obj.value(QStringLiteral("definitionId")).toString()); - const QString holding = normalizeAccountId(obj.value(QStringLiteral("holding")).toString()); - if (definitionId.isEmpty() || holding.isEmpty()) { - qWarning() << "AmmUiBackend::tokenList: skipping token" << symbol - << "— cannot normalize definitionId/holding to hex (not valid hex or base58)"; - continue; - } - - QVariantMap token; - token[QStringLiteral("symbol")] = symbol; - token[QStringLiteral("name")] = obj.value(QStringLiteral("name")).toString(); - token[QStringLiteral("definitionId")] = definitionId; - token[QStringLiteral("holding")] = holding; - token[QStringLiteral("decimals")] = obj.value(QStringLiteral("decimals")).toInt(); - out.append(token); - } - return out; + return m_logos->amm_module.tokenList(); } diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 005d4726..3cda4eba 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -12,20 +12,23 @@ #include "rep_AmmUiBackend_source.h" -#include "ActiveNetwork.h" #include "WalletAccountModel.h" class LogosAPI; struct LogosModules; -class AmmClient; class LogosWalletProvider; -class NewPositionRuntime; -class SwapRuntime; class WalletController; // Source-side implementation of the AmmUiBackend .rep interface. // Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and // SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. +// +// The AMM business logic (pool resolution, swaps, add-liquidity) lives in the +// amm_module core Logos module; this backend owns only wallet-session lifecycle +// and forwards every AMM slot to modules().amm_module. The one exception is +// creating a fresh LP holding for add-liquidity: that mutates the wallet keyset, +// so it stays here (via the wallet provider, which keeps the account model and +// on-disk storage coherent) and its id is handed to the module's submit. class AmmUiBackend : public AmmUiBackendSimpleSource { Q_OBJECT Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT) @@ -53,61 +56,35 @@ public slots: bool openExisting() override; void disconnectWallet() override; - // AMM + // AMM — all forwarded to the amm_module core module. QVariantMap resolvePool(QString defAHex, QString defBHex) override; QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal) override; - // Reads the token list from TOKENS_CONFIG (see AmmUiBackend.cpp) so the - // Swap UI's token picker is config-driven instead of hardcoded. + // Reads the token list from TOKENS_CONFIG (via the module) so the Swap UI's + // token picker is config-driven instead of hardcoded. QVariantList tokenList() override; private: void syncWalletState(); + // Publishes the new-position context PROP: a local "loading" placeholder + // until wallet state (and thus the module connection) is ready, then the + // module's newPositionContext for the current hints. void publishNetworkContext(); - // Builds the new-position network context from the same sources the Swap - // view uses: ammProgramId from $AMM_PROGRAM_BIN, tokenIds from - // $TOKENS_CONFIG. status is "ready" once AMM_PROGRAM_BIN resolves, else - // "config_missing". There is no separate network config or channel probe. - ActiveNetworkSnapshot networkSnapshot(); - - // 64-char lowercase-hex AMM program id derived from $AMM_PROGRAM_BIN (empty - // if unset/unreadable); matches swapExactInput's program-id encoding. - QString ammProgramIdHex(); - - // Normalizes an account id given as either 64-char lowercase/uppercase hex - // or base58 to lowercase hex. Returns an empty QString if `id` is neither - // (or the base58 decode fails), so callers can detect and skip it. - QString normalizeAccountId(const QString& id); - - // Returns the deployed AMM program-binary bytes (a RISC Zero ProgramBinary - // .bin, not a raw ELF) from $AMM_PROGRAM_BIN, or an empty QByteArray (with a - // qWarning) if the env var is unset/unreadable/empty. - QByteArray loadAmmElf(); - LogosAPI* m_logosAPI; - // Direct module handle for the AMM/swap path (resolvePool/swapExactInput/ - // tokenList). The shared wallet provider exposes only wallet-level ops, not - // the raw account-id / get_account_public / send_generic_public_transaction - // calls the AMM path needs, so keep a thin LogosModules over the same - // LogosAPI as the wallet provider. + // Handle for the amm_module core module (resolvePool / swapExactInput / + // tokenList / new-position). The module wraps the amm_ffi brain and + // reaches the shared wallet through its own logos_execution_zone dependency; + // this backend keeps a thin LogosModules over the same LogosAPI as the + // wallet provider so both resolve that one shared wallet instance. std::unique_ptr m_logos; std::unique_ptr m_wallet; std::unique_ptr m_walletController; - std::unique_ptr m_ammClient; - std::unique_ptr m_newPosition; - std::unique_ptr m_swap; + // Sticky new-position hints (recent/resolved token ids) so a bare + // republish (wallet-state change) keeps the user's last selection. QVariantMap m_newPositionHints; - - // Network context is derived from $AMM_PROGRAM_BIN + $TOKENS_CONFIG, which are - // fixed for the process lifetime — resolve them once and cache. networkSnapshot() - // runs on the hot path (every quote) and from inside runtime callbacks, and - // tokenList() makes remote base58 conversions, so it must not recompute each call. - bool m_networkResolved = false; - QString m_ammProgramIdCache; - QStringList m_tokenIdsCache; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/NewPositionRuntime.cpp b/apps/amm/src/NewPositionRuntime.cpp deleted file mode 100644 index 3f016ef6..00000000 --- a/apps/amm/src/NewPositionRuntime.cpp +++ /dev/null @@ -1,390 +0,0 @@ -#include "NewPositionRuntime.h" - -#include -#include -#include - -#include -#include -#include -#include -#include - -#include "AmmClient.h" -#include "WalletProvider.h" - -namespace { - const char SCHEMA[] = "new-position.v1"; - constexpr qsizetype HASH_BYTES = 32; - constexpr std::size_t BASE58_BUFFER_SIZE = 45; - - QString base58TransactionId(const QString& transactionHash) - { - const QByteArray hex = transactionHash.toLatin1(); - const QByteArray bytes = QByteArray::fromHex(hex); - if (bytes.size() != HASH_BYTES || bytes.toHex() != hex) - return {}; - - std::array encoded {}; - std::size_t size = encoded.size(); - if (!b58enc(encoded.data(), &size, bytes.constData(), - static_cast(bytes.size()))) { - return {}; - } - return QString::fromLatin1( - encoded.data(), static_cast(size - 1)); - } - - QJsonObject issue(const QString& code, - const QJsonArray& blockingFields = {}) - { - return { - { QStringLiteral("code"), code }, - { QStringLiteral("recoverable"), true }, - { QStringLiteral("blockingFields"), blockingFields }, - { QStringLiteral("details"), QJsonObject() }, - }; - } - - QJsonObject publicError(const QString& code, - const QJsonArray& blockingFields = {}, - const QJsonObject& details = {}) - { - QJsonObject error = issue(code, blockingFields); - error.insert(QStringLiteral("details"), details); - return { - { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, - { QStringLiteral("status"), QStringLiteral("error") }, - { QStringLiteral("canSubmit"), false }, - { QStringLiteral("code"), code }, - { QStringLiteral("errors"), QJsonArray { error } }, - { QStringLiteral("warnings"), QJsonArray() }, - { QStringLiteral("accountPreview"), QJsonArray() }, - }; - } - - QJsonObject contextState(const QString& status, - const ActiveNetworkSnapshot& network, - const QString& code = {}) - { - QJsonObject state { - { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, - { QStringLiteral("status"), status }, - { QStringLiteral("networkId"), network.id }, - { QStringLiteral("networkFingerprint"), network.fingerprint }, - { QStringLiteral("tokens"), QJsonArray() }, - { QStringLiteral("feeTiers"), QJsonArray() }, - { QStringLiteral("warnings"), QJsonArray() }, - }; - if (!code.isEmpty()) - state.insert(QStringLiteral("code"), code); - return state; - } - - QJsonArray variantStringArray(const QVariant& value) - { - QJsonArray result; - for (const QVariant& item : value.toList()) - result.append(item.toString()); - return result; - } - - QStringList jsonStringList(const QJsonArray& values) - { - QStringList result; - result.reserve(values.size()); - for (const QJsonValue& value : values) - result.append(value.toString()); - return result; - } - - QVector jsonBoolList(const QJsonArray& values) - { - QVector result; - result.reserve(values.size()); - for (const QJsonValue& value : values) - result.append(value.toBool()); - return result; - } - - QVector jsonUIntList(const QJsonArray& values) - { - QVector result; - result.reserve(values.size()); - for (const QJsonValue& value : values) - result.append(static_cast(value.toInteger())); - return result; - } - - QJsonObject accountReadJson(const WalletAccountRead& read) - { - QJsonObject result { - { QStringLiteral("id"), read.accountId }, - { QStringLiteral("status"), read.status }, - }; - if (read.ok()) { - result.insert(QStringLiteral("account"), QJsonObject { - { QStringLiteral("program_owner"), read.programOwner }, - { QStringLiteral("balance"), read.balanceHex }, - { QStringLiteral("nonce"), read.nonceHex }, - { QStringLiteral("data"), read.dataHex }, - }); - } - return result; - } - - QJsonArray accountReadsJson(const QVector& reads) - { - QJsonArray result; - for (const WalletAccountRead& read : reads) - result.append(accountReadJson(read)); - return result; - } -} - -NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, AmmClient* client) - : m_wallet(wallet), - m_client(client) -{ -} - -void NewPositionRuntime::clearWalletAccounts() -{ - m_wallet->clearSnapshot(); -} - -QJsonArray NewPositionRuntime::walletAccountReads(bool walletOpen, bool refresh) const -{ - if (!walletOpen) - return {}; - return accountReadsJson(m_wallet->snapshot(refresh).publicAccountReads); -} - -QJsonObject NewPositionRuntime::buildQuoteInput(const QVariantMap& request, - const ActiveNetworkSnapshot& network, - bool walletOpen, - bool freshWalletAccounts, - QJsonObject* error) const -{ - if (network.status != QStringLiteral("ready")) { - *error = publicError(network.status); - return {}; - } - const AmmClientResult configResult = m_client->configId( - QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); - if (!configResult.ok) { - *error = publicError(QStringLiteral("backend_error")); - return {}; - } - const QJsonObject configManifest = configResult.value; - const QJsonObject config = accountReadJson(m_wallet->readPublicAccount( - configManifest.value(QStringLiteral("configId")).toString())); - const QJsonObject requestObject = QJsonObject::fromVariantMap(request); - const AmmClientResult pairResult = m_client->pairIds( - QJsonObject { - { QStringLiteral("ammProgramId"), network.ammProgramId }, - { QStringLiteral("config"), config }, - { QStringLiteral("tokenAId"), requestObject.value(QStringLiteral("tokenAId")) }, - { QStringLiteral("tokenBId"), requestObject.value(QStringLiteral("tokenBId")) }, - }); - if (!pairResult.ok) { - *error = publicError(QStringLiteral("backend_error")); - return {}; - } - const QJsonObject pairManifest = pairResult.value; - if (pairManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) { - *error = publicError(pairManifest.value(QStringLiteral("code")).toString()); - return {}; - } - - const QJsonArray walletAccounts = walletAccountReads(walletOpen, freshWalletAccounts); - const QJsonObject snapshot { - { QStringLiteral("config"), config }, - { QStringLiteral("tokenA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenAId")).toString())) }, - { QStringLiteral("tokenB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenBId")).toString())) }, - { QStringLiteral("pool"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("poolId")).toString())) }, - { QStringLiteral("vaultA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultAId")).toString())) }, - { QStringLiteral("vaultB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultBId")).toString())) }, - { QStringLiteral("lpDefinition"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpDefinitionId")).toString())) }, - { QStringLiteral("lpLockHolding"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpLockHoldingId")).toString())) }, - { QStringLiteral("currentTick"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("currentTickId")).toString())) }, - { QStringLiteral("clock"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("clockId")).toString())) }, - { QStringLiteral("walletAvailable"), walletOpen }, - { QStringLiteral("walletAccounts"), walletAccounts }, - }; - return { - { QStringLiteral("networkId"), network.id }, - { QStringLiteral("networkFingerprint"), network.fingerprint }, - { QStringLiteral("ammProgramId"), network.ammProgramId }, - { QStringLiteral("request"), requestObject }, - { QStringLiteral("snapshot"), snapshot }, - }; -} - -QVariantMap NewPositionRuntime::context(const QVariantMap& request, - const ActiveNetworkSnapshot& network, - bool walletOpen, - bool refreshWalletAccounts) -{ - if (network.status != QStringLiteral("ready")) - return contextState(network.status, network).toVariantMap(); - - const QJsonArray walletAccounts = walletAccountReads(walletOpen, refreshWalletAccounts); - - const QJsonObject hints = QJsonObject::fromVariantMap(request); - const AmmClientResult configResult = m_client->configId( - QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); - if (!configResult.ok) - return contextState( - QStringLiteral("error"), network, QStringLiteral("backend_error")).toVariantMap(); - - const QJsonObject configManifest = configResult.value; - const QJsonObject config = accountReadJson(m_wallet->readPublicAccount( - configManifest.value(QStringLiteral("configId")).toString())); - QJsonArray configured; - for (const QString& id : network.tokenIds) - configured.append(id); - const QJsonArray recent = variantStringArray(hints.value(QStringLiteral("recentTokenIds")).toVariant()); - const QJsonArray resolved = variantStringArray(hints.value(QStringLiteral("resolvedTokenIds")).toVariant()); - - const AmmClientResult tokenResult = m_client->tokenIds( - QJsonObject { - { QStringLiteral("ammProgramId"), network.ammProgramId }, - { QStringLiteral("config"), config }, - { QStringLiteral("walletAccounts"), walletAccounts }, - { QStringLiteral("configuredTokenIds"), configured }, - { QStringLiteral("recentTokenIds"), recent }, - { QStringLiteral("resolvedTokenIds"), resolved }, - }); - const QJsonObject tokenManifest = tokenResult.value; - if (!tokenResult.ok - || tokenManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) { - const QString code = tokenResult.ok - ? tokenManifest.value(QStringLiteral("code")).toString() - : QStringLiteral("backend_error"); - return contextState( - QStringLiteral("error"), - network, - code.isEmpty() ? QStringLiteral("backend_error") : code).toVariantMap(); - } - - QJsonArray definitions; - for (const QJsonValue& id : tokenManifest.value(QStringLiteral("tokenIds")).toArray()) - definitions.append(accountReadJson(m_wallet->readPublicAccount(id.toString()))); - - const AmmClientResult contextResult = m_client->context( - QJsonObject { - { QStringLiteral("networkId"), network.id }, - { QStringLiteral("networkFingerprint"), network.fingerprint }, - { QStringLiteral("ammProgramId"), network.ammProgramId }, - { QStringLiteral("walletAvailable"), walletOpen }, - { QStringLiteral("config"), config }, - { QStringLiteral("walletAccounts"), walletAccounts }, - { QStringLiteral("tokenDefinitions"), definitions }, - { QStringLiteral("configuredTokenIds"), configured }, - { QStringLiteral("recentTokenIds"), recent }, - { QStringLiteral("resolvedTokenIds"), resolved }, - }); - return (contextResult.ok - ? contextResult.value - : contextState( - QStringLiteral("error"), network, QStringLiteral("backend_error"))).toVariantMap(); -} - -QVariantMap NewPositionRuntime::quote(const QVariantMap& request, - const ActiveNetworkSnapshot& network, - bool walletOpen) -{ - QJsonObject error; - const QJsonObject input = buildQuoteInput(request, network, walletOpen, false, &error); - if (!error.isEmpty()) - return error.toVariantMap(); - - const AmmClientResult result = m_client->quote(input); - return (result.ok ? result.value : publicError(QStringLiteral("backend_error"))).toVariantMap(); -} - -QVariantMap NewPositionRuntime::submit(const QVariantMap& request, - const QString& quoteHash, - const ActiveNetworkSnapshot& network, - bool walletOpen) -{ - if (m_submitInFlight) - return publicError(QStringLiteral("submit_in_progress")).toVariantMap(); - if (!walletOpen) - return publicError(QStringLiteral("wallet_unavailable")).toVariantMap(); - QScopedValueRollback submitGuard(m_submitInFlight, true); - - QJsonObject error; - const QJsonObject input = buildQuoteInput(request, network, walletOpen, true, &error); - if (!error.isEmpty()) - return error.toVariantMap(); - - const AmmClientResult quoteResult = m_client->quote(input); - if (!quoteResult.ok) - return publicError(QStringLiteral("backend_error")).toVariantMap(); - const QJsonObject quote = quoteResult.value; - if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) { - QJsonObject result = publicError(QStringLiteral("quote_changed")); - result.insert(QStringLiteral("quote"), quote); - return result.toVariantMap(); - } - if (!quote.value(QStringLiteral("canSubmit")).toBool(false)) { - QJsonObject result = publicError(QStringLiteral("quote_not_submittable")); - result.insert(QStringLiteral("quote"), quote); - return result.toVariantMap(); - } - - QJsonValue freshLp; - if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { - const WalletAccountCreation creation = m_wallet->createAccount(true); - if (!creation.ok() || !creation.publicAccount.ok()) - return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); - freshLp = accountReadJson(creation.publicAccount); - } - - QJsonObject planInput = input; - planInput.insert(QStringLiteral("quoteHash"), quoteHash); - planInput.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch()); - if (!freshLp.isUndefined()) - planInput.insert(QStringLiteral("freshLp"), freshLp); - - const AmmClientResult planResult = m_client->plan(planInput); - if (!planResult.ok) - return publicError(QStringLiteral("backend_error")).toVariantMap(); - const QJsonObject plan = planResult.value; - if (plan.value(QStringLiteral("status")).toString() != QStringLiteral("ready")) { - const QString code = plan.value(QStringLiteral("code")).toString(); - return publicError(code.isEmpty() ? QStringLiteral("wallet_submission_failed") : code) - .toVariantMap(); - } - - const QStringList accountIds = jsonStringList(plan.value(QStringLiteral("accountIds")).toArray()); - const QVector signingRequirements = jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray()); - const QVector instruction = jsonUIntList(plan.value(QStringLiteral("instruction")).toArray()); - const QString programId = plan.value(QStringLiteral("programId")).toString(); - bool deadlineValid = false; - const qulonglong deadline = plan.value(QStringLiteral("deadlineMs")).toString().toULongLong(&deadlineValid); - if (!deadlineValid - || static_cast(QDateTime::currentMSecsSinceEpoch()) >= deadline) { - return publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap(); - } - const WalletSubmission submission = m_wallet->submitPublicTransaction({ - programId, - accountIds, - signingRequirements, - instruction, - }); - if (!submission.accepted()) - return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); - const QString transactionId = base58TransactionId(submission.nativeHash); - if (transactionId.isEmpty()) - return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); - - return QJsonObject { - { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, - { QStringLiteral("status"), QStringLiteral("submitted") }, - { QStringLiteral("transactionId"), transactionId }, - { QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) }, - }.toVariantMap(); -} diff --git a/apps/amm/src/NewPositionRuntime.h b/apps/amm/src/NewPositionRuntime.h deleted file mode 100644 index 99e190e9..00000000 --- a/apps/amm/src/NewPositionRuntime.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "ActiveNetwork.h" - -class AmmClient; -class WalletProvider; - -class NewPositionRuntime { -public: - NewPositionRuntime(WalletProvider* wallet, AmmClient* client); - - void clearWalletAccounts(); - - QVariantMap context(const QVariantMap& request, - const ActiveNetworkSnapshot& network, - bool walletOpen, - bool refreshWalletAccounts); - QVariantMap quote(const QVariantMap& request, - const ActiveNetworkSnapshot& network, - bool walletOpen); - QVariantMap submit(const QVariantMap& request, - const QString& quoteHash, - const ActiveNetworkSnapshot& network, - bool walletOpen); - -private: - QJsonArray walletAccountReads(bool walletOpen, bool refresh) const; - QJsonObject buildQuoteInput(const QVariantMap& request, - const ActiveNetworkSnapshot& network, - bool walletOpen, - bool freshWalletAccounts, - QJsonObject* error) const; - - WalletProvider* m_wallet; - AmmClient* m_client; - bool m_submitInFlight = false; -}; diff --git a/apps/amm/src/SwapRuntime.cpp b/apps/amm/src/SwapRuntime.cpp deleted file mode 100644 index ea423d24..00000000 --- a/apps/amm/src/SwapRuntime.cpp +++ /dev/null @@ -1,145 +0,0 @@ -#include "SwapRuntime.h" - -#include -#include -#include - -#include "AmmClient.h" -#include "WalletProvider.h" - -namespace { - QJsonObject accountReadJson(const WalletAccountRead& read) - { - QJsonObject result { - { QStringLiteral("id"), read.accountId }, - { QStringLiteral("status"), read.status }, - }; - if (read.ok()) { - result.insert(QStringLiteral("account"), QJsonObject { - { QStringLiteral("program_owner"), read.programOwner }, - { QStringLiteral("balance"), read.balanceHex }, - { QStringLiteral("nonce"), read.nonceHex }, - { QStringLiteral("data"), read.dataHex }, - }); - } - return result; - } - - QStringList jsonStringList(const QJsonArray& values) - { - QStringList result; - result.reserve(values.size()); - for (const QJsonValue& value : values) - result.append(value.toString()); - return result; - } - - QVector jsonBoolList(const QJsonArray& values) - { - QVector result; - result.reserve(values.size()); - for (const QJsonValue& value : values) - result.append(value.toBool()); - return result; - } - - QVector jsonUIntList(const QJsonArray& values) - { - QVector result; - result.reserve(values.size()); - for (const QJsonValue& value : values) - result.append(static_cast(value.toInteger())); - return result; - } -} - -SwapRuntime::SwapRuntime(WalletProvider* wallet, AmmClient* client) - : m_wallet(wallet), - m_client(client) -{ -} - -QJsonObject SwapRuntime::readConfig(const ActiveNetworkSnapshot& network) const -{ - const AmmClientResult configResult = m_client->configId( - QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); - if (!configResult.ok) - return {}; - return accountReadJson(m_wallet->readPublicAccount( - configResult.value.value(QStringLiteral("configId")).toString())); -} - -QVariantMap SwapRuntime::resolvePool(const QString& tokenInId, - const QString& tokenOutId, - const ActiveNetworkSnapshot& network) -{ - const QVariantMap absent { { QStringLiteral("exists"), false } }; - if (network.status != QStringLiteral("ready")) - return absent; - - const QJsonObject config = readConfig(network); - if (config.isEmpty()) - return absent; - - const AmmClientResult pairResult = m_client->swapPair(QJsonObject { - { QStringLiteral("ammProgramId"), network.ammProgramId }, - { QStringLiteral("tokenInId"), tokenInId }, - { QStringLiteral("tokenOutId"), tokenOutId }, - { QStringLiteral("config"), config }, - }); - if (!pairResult.ok - || pairResult.value.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) - return absent; - - const QJsonObject pool = accountReadJson(m_wallet->readPublicAccount( - pairResult.value.value(QStringLiteral("poolId")).toString())); - const AmmClientResult resolveResult = - m_client->resolvePool(QJsonObject { { QStringLiteral("pool"), pool } }); - if (!resolveResult.ok) - return absent; - return resolveResult.value.toVariantMap(); -} - -QString SwapRuntime::swap(const QString& tokenInId, - const QString& tokenOutId, - const QString& userInputHoldingId, - const QString& userOutputHoldingId, - const QString& amountInDecimal, - const QString& minOutDecimal, - const QString& deadlineMs, - const ActiveNetworkSnapshot& network, - bool walletOpen) -{ - if (network.status != QStringLiteral("ready") || !walletOpen) - return {}; - - const QJsonObject config = readConfig(network); - if (config.isEmpty()) - return {}; - - const AmmClientResult planResult = m_client->swapPlan(QJsonObject { - { QStringLiteral("ammProgramId"), network.ammProgramId }, - { QStringLiteral("tokenInId"), tokenInId }, - { QStringLiteral("tokenOutId"), tokenOutId }, - { QStringLiteral("config"), config }, - { QStringLiteral("userInputHoldingId"), userInputHoldingId }, - { QStringLiteral("userOutputHoldingId"), userOutputHoldingId }, - { QStringLiteral("amountIn"), amountInDecimal }, - { QStringLiteral("minOut"), minOutDecimal }, - { QStringLiteral("deadlineMs"), deadlineMs }, - }); - if (!planResult.ok - || planResult.value.value(QStringLiteral("status")).toString() != QStringLiteral("ready")) - return {}; - - const QJsonObject plan = planResult.value; - const WalletSubmission submission = m_wallet->submitPublicTransaction({ - plan.value(QStringLiteral("programId")).toString(), - jsonStringList(plan.value(QStringLiteral("accountIds")).toArray()), - jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray()), - jsonUIntList(plan.value(QStringLiteral("instruction")).toArray()), - }); - if (!submission.accepted()) - return {}; - return submission.nativeHash; -} diff --git a/apps/amm/src/SwapRuntime.h b/apps/amm/src/SwapRuntime.h deleted file mode 100644 index 4bf04f5a..00000000 --- a/apps/amm/src/SwapRuntime.h +++ /dev/null @@ -1,46 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "ActiveNetwork.h" - -class AmmClient; -class WalletProvider; - -// Off-chain orchestration for the Swap view: reads accounts through the wallet, -// drives the amm_client swap ops, and submits the swap transaction. The AMM -// domain logic lives in the amm_client crate; this class only glues account -// reads and submission to it. Mirrors NewPositionRuntime. -class SwapRuntime { -public: - SwapRuntime(WalletProvider* wallet, AmmClient* client); - - // { exists, reserveA, reserveB, feeBps } for the (tokenIn, tokenOut) pool. - // reserveA/reserveB follow the pool's canonical def order (the caller maps - // them to sell/buy). exists=false when the pool is absent or has no liquidity. - QVariantMap resolvePool(const QString& tokenInId, - const QString& tokenOutId, - const ActiveNetworkSnapshot& network); - - // Builds and submits a SwapExactInput transaction; returns the native tx - // hash on success, an empty string on any failure. - QString swap(const QString& tokenInId, - const QString& tokenOutId, - const QString& userInputHoldingId, - const QString& userOutputHoldingId, - const QString& amountInDecimal, - const QString& minOutDecimal, - const QString& deadlineMs, - const ActiveNetworkSnapshot& network, - bool walletOpen); - -private: - // Derives the config account id (config_id) and reads it. Returns an empty - // object only when the config_id op itself fails. - QJsonObject readConfig(const ActiveNetworkSnapshot& network) const; - - WalletProvider* m_wallet; - AmmClient* m_client; -}; diff --git a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp deleted file mode 100644 index 38f174f9..00000000 --- a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp +++ /dev/null @@ -1,230 +0,0 @@ -#include "AmmClient.h" -#include "NewPositionRuntime.h" -#include "WalletProvider.h" - -#include - -namespace { - bool expect(bool condition, const char* message) - { - if (!condition) - qCritical("%s", message); - return condition; - } - - class FakeWallet final : public WalletProvider { - public: - WalletSession connect(const WalletPaths&) override - { - return {}; - } - - WalletCreation createWallet(const WalletPaths&, const QString&) override - { - return {}; - } - - WalletSnapshot snapshot(bool) override - { - return {}; - } - - void clearSnapshot() override {} - - WalletAccountCreation createAccount(bool isPublic) override - { - ++createdAccounts; - WalletAccountCreation creation; - creation.accountId = QStringLiteral("fresh-lp"); - if (isPublic) - creation.publicAccount = readPublicAccount(creation.accountId); - return creation; - } - - WalletAccountRead readPublicAccount(const QString& accountId) const override - { - WalletAccountRead read; - read.accountId = accountId; - read.status = QStringLiteral("ok"); - return read; - } - - WalletSubmission submitPublicTransaction(const WalletTransaction& transaction) override - { - ++submissions; - submitted = transaction; - return { WalletFailure::None, transactionHash }; - } - - void disconnect() override {} - - QString transactionHash = QStringLiteral( - "000102030405060708090a0b0c0d0e0f" - "101112131415161718191a1b1c1d1e1f"); - int createdAccounts = 0; - int submissions = 0; - WalletTransaction submitted; - }; - - class FakeAmmClient final : public AmmClient { - public: - AmmClientResult configId(const QJsonObject&) const override - { - return success({ { QStringLiteral("configId"), QStringLiteral("config") } }); - } - - AmmClientResult tokenIds(const QJsonObject&) const override - { - return success({ { QStringLiteral("status"), QStringLiteral("ok") } }); - } - - AmmClientResult pairIds(const QJsonObject&) const override - { - return success({ - { QStringLiteral("status"), QStringLiteral("ok") }, - { QStringLiteral("tokenAId"), QStringLiteral("token-a") }, - { QStringLiteral("tokenBId"), QStringLiteral("token-b") }, - { QStringLiteral("poolId"), QStringLiteral("pool") }, - { QStringLiteral("vaultAId"), QStringLiteral("vault-a") }, - { QStringLiteral("vaultBId"), QStringLiteral("vault-b") }, - { QStringLiteral("lpDefinitionId"), QStringLiteral("lp") }, - { QStringLiteral("lpLockHoldingId"), QStringLiteral("lp-lock") }, - { QStringLiteral("currentTickId"), QStringLiteral("tick") }, - { QStringLiteral("clockId"), QStringLiteral("clock") }, - }); - } - - AmmClientResult context(const QJsonObject&) const override - { - return success({}); - } - - AmmClientResult quote(const QJsonObject&) const override - { - return success({ - { QStringLiteral("schema"), QStringLiteral("new-position.v1") }, - { QStringLiteral("status"), QStringLiteral("ok") }, - { QStringLiteral("canSubmit"), true }, - { QStringLiteral("quoteHash"), quoteHash }, - { QStringLiteral("requiresFreshLp"), requiresFreshLp }, - }); - } - - AmmClientResult plan(const QJsonObject& request) const override - { - sawFreshLp = request.contains(QStringLiteral("freshLp")); - return success({ - { QStringLiteral("status"), QStringLiteral("ready") }, - { QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } }, - { QStringLiteral("signingRequirements"), QJsonArray { true } }, - { QStringLiteral("instruction"), QJsonArray { 1 } }, - { QStringLiteral("programId"), QStringLiteral("program") }, - { QStringLiteral("deadlineMs"), - QString::number(QDateTime::currentMSecsSinceEpoch() + 60'000) }, - }); - } - - static AmmClientResult success(const QJsonObject& value) - { - return { true, value }; - } - - QString quoteHash = QStringLiteral("sha256:expected"); - bool requiresFreshLp = true; - mutable bool sawFreshLp = false; - }; - - ActiveNetworkSnapshot readyNetwork() - { - return { - QStringLiteral("testnet"), - QStringLiteral("ready"), - QStringLiteral("block10:identity"), - QStringLiteral("program"), - {}, - }; - } -} - -int main() -{ - const QVariantMap request { - { QStringLiteral("schema"), QStringLiteral("new-position.v1") }, - { QStringLiteral("tokenAId"), QStringLiteral("token-a") }, - { QStringLiteral("tokenBId"), QStringLiteral("token-b") }, - { QStringLiteral("feeBps"), 30 }, - }; - - FakeWallet wallet; - FakeAmmClient client; - NewPositionRuntime runtime(&wallet, &client); - const QVariantMap result = runtime.submit( - request, QStringLiteral("sha256:expected"), readyNetwork(), true); - if (!expect(result.value(QStringLiteral("status")).toString() - == QStringLiteral("submitted"), - "valid plan should submit")) - return 1; - if (!expect(result.value(QStringLiteral("transactionId")).toString() - == QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"), - "native hash should become the expected base58 transaction ID")) - return 1; - if (!expect(wallet.createdAccounts == 1 && client.sawFreshLp, - "fresh LP account should enter the plan")) - return 1; - if (!expect(wallet.submissions == 1 - && wallet.submitted.accountIds - == QStringList { QStringLiteral("account") } - && wallet.submitted.signingRequirements.size() == 1 - && wallet.submitted.signingRequirements.constFirst() - && wallet.submitted.instruction.size() == 1 - && wallet.submitted.instruction.constFirst() == 1 - && wallet.submitted.programId == QStringLiteral("program"), - "runtime should dispatch the unchanged plan once")) - return 1; - - FakeWallet orderedBytesWallet; - orderedBytesWallet.transactionHash = QStringLiteral( - "0102030405060708090a0b0c0d0e0f10" - "1112131415161718191a1b1c1d1e1f20"); - FakeAmmClient orderedBytesClient; - orderedBytesClient.requiresFreshLp = false; - NewPositionRuntime orderedBytesRuntime(&orderedBytesWallet, &orderedBytesClient); - const QVariantMap orderedBytes = orderedBytesRuntime.submit( - request, QStringLiteral("sha256:expected"), readyNetwork(), true); - if (!expect(orderedBytes.value(QStringLiteral("transactionId")).toString() - == QStringLiteral("4wBqpZM9xaSheZzJSMawUKKwhdpChKbZ5eu5ky4Vigw"), - "ordered native hash bytes should preserve byte order")) - return 1; - - FakeWallet invalidHashWallet; - invalidHashWallet.transactionHash = QStringLiteral("not-a-hash"); - FakeAmmClient invalidHashClient; - invalidHashClient.requiresFreshLp = false; - NewPositionRuntime invalidHashRuntime(&invalidHashWallet, &invalidHashClient); - const QVariantMap invalidHash = invalidHashRuntime.submit( - request, QStringLiteral("sha256:expected"), readyNetwork(), true); - if (!expect(invalidHash.value(QStringLiteral("code")).toString() - == QStringLiteral("wallet_submission_failed") - && !invalidHash.contains(QStringLiteral("transactionId")), - "invalid native hash should fail without a hex fallback")) - return 1; - if (!expect(invalidHashWallet.submissions == 1, - "hash conversion should happen after wallet submission")) - return 1; - - FakeWallet staleWallet; - FakeAmmClient staleClient; - staleClient.quoteHash = QStringLiteral("sha256:changed"); - NewPositionRuntime staleRuntime(&staleWallet, &staleClient); - const QVariantMap stale = staleRuntime.submit( - request, QStringLiteral("sha256:expected"), readyNetwork(), true); - if (!expect(stale.value(QStringLiteral("code")).toString() - == QStringLiteral("quote_changed"), - "changed quote should stop submission")) - return 1; - if (!expect(staleWallet.createdAccounts == 0 && staleWallet.submissions == 0, - "stale quote should have no wallet side effects")) - return 1; - - return 0; -} diff --git a/flake.nix b/flake.nix index 1fa131a8..1adcc932 100644 --- a/flake.nix +++ b/flake.nix @@ -11,7 +11,7 @@ }; # The AMM QML UI module (apps/amm) is built from this same flake so it can - # reference the amm_client crate package via `self` — no filesystem + # reference the amm_ffi crate package via `self` — no filesystem # path or git-remote reference to this repo is needed (see apps/amm/flake.nix # history: a `git+file://` URL pointing at a local checkout is # machine-specific and not portable). @@ -54,19 +54,19 @@ craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; # Whole workspace: crane needs Cargo.lock + all path deps (amm_core, - # twap_oracle_core, token_core, ...) to resolve `-p amm_client`. + # twap_oracle_core, token_core, ...) to resolve `-p amm_ffi`. src = ./.; commonArgs = { inherit src; strictDeps = true; - pname = "amm_client"; + pname = "amm_ffi"; version = "0.1.0"; # CRITICAL: scope to ONLY this crate. The workspace also contains # `amm/methods` etc. whose build.rs compiles the risc0 guest (which - # WOULD invoke Metal on darwin). `-p amm_client` never builds + # WOULD invoke Metal on darwin). `-p amm_ffi` never builds # those crates or their build scripts. - cargoExtraArgs = "-p amm_client"; + cargoExtraArgs = "-p amm_ffi"; doCheck = false; # NOTE: cbindgen is used here as a Cargo *build-dependency* # (invoked from build.rs via its Rust library API), not as the @@ -76,17 +76,17 @@ cargoArtifacts = craneLib.buildDepsOnly commonArgs; - # The single AMM host FFI crate (apps/amm/client): swap + new-position + # The single AMM host FFI crate (modules/amm/ffi): swap + new-position # operations behind one JSON C ABI. Its header is cbindgen-generated - # into include/amm_client.h at build time. - ammClient = craneLib.buildPackage ( + # into include/amm_ffi.h at build time. + ammFfi = craneLib.buildPackage ( commonArgs // { inherit cargoArtifacts; postInstall = '' mkdir -p $out/include - cp apps/amm/client/include/amm_client.h $out/include/ + cp modules/amm/ffi/include/amm_ffi.h $out/include/ '' + pkgs.lib.optionalString pkgs.stdenv.isDarwin '' # Set the dylib's install-name to its ABSOLUTE store path (NOT @@ -95,32 +95,37 @@ # @rpath id fails to dlopen at launch. An absolute /nix/store id # is recorded in the plugin's LC_LOAD_DYLIB, kept in the closure # by Nix, and resolved directly at runtime — no rpath needed. - if [ -f $out/lib/libamm_client.dylib ]; then - install_name_tool -id "$out/lib/libamm_client.dylib" $out/lib/libamm_client.dylib + if [ -f $out/lib/libamm_ffi.dylib ]; then + install_name_tool -id "$out/lib/libamm_ffi.dylib" $out/lib/libamm_ffi.dylib fi ''; } ); in { - packages.default = ammClient; - packages.amm_client = ammClient; + packages.default = ammFfi; + packages.amm_ffi = ammFfi; } ); - # The AMM QML UI module (apps/amm). Its external_libraries entry - # (amm_client) is resolved to `self.packages.${system}.amm_client` - # above — the module builder's resolveExtInput reads - # `flakeInput.packages.${system}.${pkgName}`, and passing `self` here - # works because `self` is this very flake, which already exposes that - # package per crateOutputs above. + # The AMM QML UI module (apps/amm). It links no amm_ffi library of its + # own — the AMM logic lives in the amm_module core module, which the UI + # depends on (declared in apps/amm/metadata.json, reached via + # modules().amm_module in the backend) alongside the logos_execution_zone + # wallet module. appOutputs = logos-module-builder.lib.mkLogosQmlModule { src = ./apps/amm; configFile = ./apps/amm/metadata.json; - flakeInputs = inputs; - externalLibInputs = { - amm_client = { input = self; packages.default = "amm_client"; }; - }; + # amm_module isn't a flake input — it's built from this same flake below + # (ammModuleOutputs) — so inject it into flakeInputs under its dependency + # name so the builder's dependency resolver finds it (reads its .lidl to + # generate the modules().amm_module wrapper). The wallet module stays a + # direct dep too, so both the UI and amm_module resolve the one shared + # wallet instance. + flakeInputs = inputs // { amm_module = ammModuleOutputs; }; + # The UI links no external lib of its own — the AMM brain (amm_ffi) is + # linked by amm_module, which the UI reaches via modules().amm_module. + externalLibInputs = { }; # The AMM UI links the shared C++ wallet access lib and bundles the # Logos.Wallet QML module (apps/shared/wallet). apps/amm/flake.nix wires # these via its `shared_wallet` input; when built from this root flake @@ -153,20 +158,36 @@ appApps = appOutputs.apps or { }; appPkgs = appOutputs.packages or { }; + # AMM core module (modules/amm): the AMM business logic as a headless + # `core` Logos module. It links the amm_ffi crate (the transport- + # independent AMM brain, resolved via `self`) and depends on the + # logos_execution_zone wallet module (declared in modules/amm/metadata.json, + # reached via modules().logos_execution_zone in the impl). Exposed as the + # `amm-module` package; no UI/app output. + ammModuleOutputs = logos-module-builder.lib.mkLogosModule { + src = ./modules/amm; + configFile = ./modules/amm/metadata.json; + flakeInputs = inputs; + externalLibInputs = { + amm_ffi = { input = self; packages.default = "amm_ffi"; }; + }; + }; + ammModulePkgs = ammModuleOutputs.packages or { }; + # Wrap the app launcher to export DYLD_FALLBACK_LIBRARY_PATH pointing at the - # amm_client lib. The logos module builder links the plugin against - # @rpath/libamm_client.dylib but does NOT stage that dylib into the + # amm_ffi lib. The logos module builder links the plugin against + # @rpath/libamm_ffi.dylib but does NOT stage that dylib into the # plugin-dir it loads at runtime, so dlopen fails with "Failed to load UI # plugin". Adding the crate's store lib dir to DYLD's fallback search path # lets the loader find it (the store path stays in the closure). wrapWithDyld = system: app: let pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; }; - ammClient = crateOutputs.packages.${system}.amm_client; + ammFfi = crateOutputs.packages.${system}.amm_ffi; in app // { program = "${pkgs.writeShellScript "run-amm-ui" '' - export DYLD_FALLBACK_LIBRARY_PATH="${ammClient}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" + export DYLD_FALLBACK_LIBRARY_PATH="${ammFfi}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" exec ${app.program} "$@" ''}"; }; @@ -180,10 +201,13 @@ system: cratePkgs: let appSysPkgs = appPkgs.${system} or { }; + ammModSysPkgs = ammModulePkgs.${system} or { }; in (builtins.removeAttrs cratePkgs [ "default" ]) // (builtins.removeAttrs appSysPkgs [ "default" ]) // (if appSysPkgs ? default then { amm-ui = appSysPkgs.default; } else { }) + // (builtins.removeAttrs ammModSysPkgs [ "default" ]) + // (if ammModSysPkgs ? default then { amm-module = ammModSysPkgs.default; } else { }) ) crateOutputs.packages; in (builtins.removeAttrs appOutputs [ "apps" "packages" ]) diff --git a/modules/amm/CMakeLists.txt b/modules/amm/CMakeLists.txt new file mode 100644 index 00000000..3c234946 --- /dev/null +++ b/modules/amm/CMakeLists.txt @@ -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_ffi — the pure-Rust, +# transport-independent AMM brain (PDA/decode/quote/plan/encode) exposed as a +# JSON FFI (amm_ffi.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_ffi +) diff --git a/modules/amm/README.md b/modules/amm/README.md new file mode 100644 index 00000000..5ce4142e --- /dev/null +++ b/modules/amm/README.md @@ -0,0 +1,260 @@ +# 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_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-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_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 `. + +**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): + +```bash +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, `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_ffi.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. + Swaps also need the wallet **open + synced** (see below); reads + (`resolvePool`/`tokenList`) do not. + +### 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`: + +```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 +``` + +`resolvePool`/`tokenList` are read-only: + +```bash +logoscore call amm_module tokenList +logoscore call amm_module resolvePool +``` + +`swapExactInput` needs an **open, synced wallet** pointed at your sequencer +(nothing opens it for you headlessly), then the swap. Note the amount/deadline +conventions above — small amount bare, deadline (and any big amount) quoted: + +```bash +logoscore call logos_execution_zone open ~/.lee/wallet/wallet_config.json ~/.lee/wallet/storage.json +logoscore call amm_module swapExactInput \ + \ + 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 ` to re-sync from +genesis. `resolvePool` is unaffected — it reads live from the sequencer. + +## 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`). 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_ffi` 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. diff --git a/apps/amm/client/Cargo.toml b/modules/amm/ffi/Cargo.toml similarity index 97% rename from apps/amm/client/Cargo.toml rename to modules/amm/ffi/Cargo.toml index d3e2be76..17392867 100644 --- a/apps/amm/client/Cargo.toml +++ b/modules/amm/ffi/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "amm_client" +name = "amm_ffi" version = "0.1.0" edition = "2021" diff --git a/apps/amm/client/build.rs b/modules/amm/ffi/build.rs similarity index 80% rename from apps/amm/client/build.rs rename to modules/amm/ffi/build.rs index 0ca66455..c6a14361 100644 --- a/apps/amm/client/build.rs +++ b/modules/amm/ffi/build.rs @@ -3,7 +3,7 @@ fn main() { std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"); cbindgen::generate(&crate_dir) .expect("cbindgen") - .write_to_file(format!("{crate_dir}/include/amm_client.h")); + .write_to_file(format!("{crate_dir}/include/amm_ffi.h")); println!("cargo:rerun-if-changed=src"); println!("cargo:rerun-if-changed=cbindgen.toml"); } diff --git a/apps/amm/client/cbindgen.toml b/modules/amm/ffi/cbindgen.toml similarity index 81% rename from apps/amm/client/cbindgen.toml rename to modules/amm/ffi/cbindgen.toml index 210f8e52..2b4ca030 100644 --- a/apps/amm/client/cbindgen.toml +++ b/modules/amm/ffi/cbindgen.toml @@ -1,5 +1,5 @@ language = "C" -include_guard = "AMM_CLIENT_H" +include_guard = "AMM_FFI_H" pragma_once = true cpp_compat = true autogen_warning = "/* Generated by cbindgen. Do not edit. */" diff --git a/apps/amm/client/include/amm_client.h b/modules/amm/ffi/include/amm_ffi.h similarity index 93% rename from apps/amm/client/include/amm_client.h rename to modules/amm/ffi/include/amm_ffi.h index 5b2eb025..69684a64 100644 --- a/apps/amm/client/include/amm_client.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -1,5 +1,5 @@ -#ifndef AMM_CLIENT_H -#define AMM_CLIENT_H +#ifndef AMM_FFI_H +#define AMM_FFI_H #pragma once @@ -46,4 +46,4 @@ void amm_free(char *value); } // extern "C" #endif // __cplusplus -#endif /* AMM_CLIENT_H */ +#endif /* AMM_FFI_H */ diff --git a/apps/amm/client/src/account.rs b/modules/amm/ffi/src/account.rs similarity index 100% rename from apps/amm/client/src/account.rs rename to modules/amm/ffi/src/account.rs diff --git a/apps/amm/client/src/api/accounts.rs b/modules/amm/ffi/src/api/accounts.rs similarity index 100% rename from apps/amm/client/src/api/accounts.rs rename to modules/amm/ffi/src/api/accounts.rs diff --git a/apps/amm/client/src/api/clock.rs b/modules/amm/ffi/src/api/clock.rs similarity index 100% rename from apps/amm/client/src/api/clock.rs rename to modules/amm/ffi/src/api/clock.rs diff --git a/apps/amm/client/src/api/commitment.rs b/modules/amm/ffi/src/api/commitment.rs similarity index 100% rename from apps/amm/client/src/api/commitment.rs rename to modules/amm/ffi/src/api/commitment.rs diff --git a/apps/amm/client/src/api/config.rs b/modules/amm/ffi/src/api/config.rs similarity index 100% rename from apps/amm/client/src/api/config.rs rename to modules/amm/ffi/src/api/config.rs diff --git a/apps/amm/client/src/api/context.rs b/modules/amm/ffi/src/api/context.rs similarity index 100% rename from apps/amm/client/src/api/context.rs rename to modules/amm/ffi/src/api/context.rs diff --git a/apps/amm/client/src/api/funding.rs b/modules/amm/ffi/src/api/funding.rs similarity index 100% rename from apps/amm/client/src/api/funding.rs rename to modules/amm/ffi/src/api/funding.rs diff --git a/apps/amm/client/src/api/holding.rs b/modules/amm/ffi/src/api/holding.rs similarity index 100% rename from apps/amm/client/src/api/holding.rs rename to modules/amm/ffi/src/api/holding.rs diff --git a/apps/amm/client/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs similarity index 100% rename from apps/amm/client/src/api/mod.rs rename to modules/amm/ffi/src/api/mod.rs diff --git a/apps/amm/client/src/api/pair.rs b/modules/amm/ffi/src/api/pair.rs similarity index 100% rename from apps/amm/client/src/api/pair.rs rename to modules/amm/ffi/src/api/pair.rs diff --git a/apps/amm/client/src/api/plan.rs b/modules/amm/ffi/src/api/plan.rs similarity index 100% rename from apps/amm/client/src/api/plan.rs rename to modules/amm/ffi/src/api/plan.rs diff --git a/apps/amm/client/src/api/position.rs b/modules/amm/ffi/src/api/position.rs similarity index 100% rename from apps/amm/client/src/api/position.rs rename to modules/amm/ffi/src/api/position.rs diff --git a/apps/amm/client/src/api/quote.rs b/modules/amm/ffi/src/api/quote.rs similarity index 100% rename from apps/amm/client/src/api/quote.rs rename to modules/amm/ffi/src/api/quote.rs diff --git a/apps/amm/client/src/api/quote_error.rs b/modules/amm/ffi/src/api/quote_error.rs similarity index 100% rename from apps/amm/client/src/api/quote_error.rs rename to modules/amm/ffi/src/api/quote_error.rs diff --git a/apps/amm/client/src/api/request.rs b/modules/amm/ffi/src/api/request.rs similarity index 100% rename from apps/amm/client/src/api/request.rs rename to modules/amm/ffi/src/api/request.rs diff --git a/apps/amm/client/src/api/swap.rs b/modules/amm/ffi/src/api/swap.rs similarity index 100% rename from apps/amm/client/src/api/swap.rs rename to modules/amm/ffi/src/api/swap.rs diff --git a/apps/amm/client/src/api/tests.rs b/modules/amm/ffi/src/api/tests.rs similarity index 100% rename from apps/amm/client/src/api/tests.rs rename to modules/amm/ffi/src/api/tests.rs diff --git a/apps/amm/client/src/ffi.rs b/modules/amm/ffi/src/ffi.rs similarity index 100% rename from apps/amm/client/src/ffi.rs rename to modules/amm/ffi/src/ffi.rs diff --git a/apps/amm/client/src/lib.rs b/modules/amm/ffi/src/lib.rs similarity index 100% rename from apps/amm/client/src/lib.rs rename to modules/amm/ffi/src/lib.rs diff --git a/apps/amm/client/tests/public_api.rs b/modules/amm/ffi/tests/public_api.rs similarity index 86% rename from apps/amm/client/tests/public_api.rs rename to modules/amm/ffi/tests/public_api.rs index 7d38289d..e5ab2798 100644 --- a/apps/amm/client/tests/public_api.rs +++ b/modules/amm/ffi/tests/public_api.rs @@ -1,4 +1,4 @@ -use amm_client::{config_id, ConfigIdRequest, NEW_POSITION_SCHEMA}; +use amm_ffi::{config_id, ConfigIdRequest, NEW_POSITION_SCHEMA}; #[test] fn direct_rust_api_does_not_require_ffi() { diff --git a/modules/amm/flake.nix b/modules/amm/flake.nix new file mode 100644 index 00000000..dc3c7cd8 --- /dev/null +++ b/modules/amm/flake.nix @@ -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_ffi + # 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_ffi 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; + }; +} diff --git a/modules/amm/metadata.json b/modules/amm/metadata.json new file mode 100644 index 00000000..827e6c5d --- /dev/null +++ b/modules/amm/metadata.json @@ -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_ffi" } + ], + "cmake": { + "find_packages": [], + "extra_sources": [], + "extra_include_dirs": [], + "extra_link_libraries": [] + } + } +} diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp new file mode 100644 index 00000000..dff3b7e7 --- /dev/null +++ b/modules/amm/src/amm_module_impl.cpp @@ -0,0 +1,742 @@ +#include "amm_module_impl.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +// 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_ffi.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(); +} + +// 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( + std::chrono::duration_cast( + 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(c - '0'); + if (value > (~static_cast(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(); + // 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()); + return true; + } + if (j.is_number_integer()) { + const int64_t v = j.get(); + 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 jsonStrVec(const json& arr) { + std::vector 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()); + return out; +} + +std::vector jsonBoolVec(const json& arr) { + std::vector out; + if (!arr.is_array()) return out; + out.reserve(arr.size()); + for (const auto& v : arr) + out.push_back(v.is_boolean() && v.get()); + return out; +} + +std::vector jsonWordsToLeBytes(const json& arr) { + std::vector 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(v.get()) : 0; + out.push_back(static_cast(word & 0xff)); + out.push_back(static_cast((word >> 8) & 0xff)); + out.push_back(static_cast((word >> 16) & 0xff)); + out.push_back(static_cast((word >> 24) & 0xff)); + } + return out; +} + +// Result of an amm_ffi JSON op: the `{ ok, value }` envelope decoded. +struct FfiResult { + bool ok = false; + json value; +}; + +// Serialize `request`, hand it to an amm_ffi 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_ffi 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_ffi op returned invalid JSON"); + return {}; + } + if (!doc.value("ok", false)) { + AMM_TRACE("amm_ffi op failure: " << doc.value("error", std::string())); + return {}; + } + const auto it = doc.find("value"); + if (it == doc.end() || !it->is_object()) { + AMM_TRACE("amm_ffi 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 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((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); +} + +std::string AmmModuleImpl::ammProgramId() { + const std::vector elf = loadAmmElf(); + if (elf.empty()) return {}; + // Hand the deployed binary to the amm_ffi 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() { + Network net; + net.amm_program_id = ammProgramId(); + // 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 = net.amm_program_id; + for (const auto& token : tokenList()) { + const std::string id = jStr(token, "definitionId"); + if (!id.empty()) net.token_ids.push_back(id); + } + net.status = net.amm_program_id.empty() ? "config_missing" : "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(id[start]))) ++start; + while (end > start && std::isspace(static_cast(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(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(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*/) { + json out = json::array(); + if (!wallet_open) return out; + + // 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 out; + + 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)); + } + 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) { + const json absent = {{"exists", false}}; + + const Network net = network(); + if (net.status != "ready") return absent; + + const json config = readConfig(net); + if (config.is_null()) return absent; + + 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 || jStr(pairResult.value, "status") != "ok") return absent; + + const json pool = readPublicAccount(jStr(pairResult.value, "poolId")); + const FfiResult resolveResult = call(amm_resolve_pool, json{{"pool", pool}}); + if (!resolveResult.ok) return absent; + return resolveResult.value; +} + +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 accounts = jsonStrVec(plan.value("accountIds", json::array())); + const std::vector signers = jsonBoolVec(plan.value("signingRequirements", json::array())); + const std::vector 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(file)), + std::istreambuf_iterator()); + + 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; + + json token; + token["symbol"] = jStr(entry, "symbol"); + token["name"] = jStr(entry, "name"); + token["definitionId"] = definition_id; + token["holding"] = holding; + token["decimals"] = entry.value("decimals", 0); + 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())); + + 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 accounts = jsonStrVec(plan.value("accountIds", json::array())); + const std::vector signers = jsonBoolVec(plan.value("signingRequirements", json::array())); + const std::vector 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())}, + }; +} diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h new file mode 100644 index 00000000..440bc8c8 --- /dev/null +++ b/modules/amm/src/amm_module_impl.h @@ -0,0 +1,146 @@ +#pragma once + +#include +#include +#include + +#include // LogosMap / LogosList (nlohmann::json aliases) +#include // 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_ffi` crate and is reached through its JSON FFI (amm_ffi.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. + /// Returns `{ "exists": false, "error": "" }` when the AMM program + /// bin isn't configured, the AMM isn't initialized, or the pool has no + /// liquidity yet; otherwise the resolved pool as a map (reserveA/reserveB in + /// the pool's canonical def order, feeBps, and the derived account ids). + 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" / "loading". + 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 token_ids; + }; + Network network(); + + // 64-char lowercase-hex AMM program id via the amm_ffi `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 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_ffi 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_ffi 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 accounts as an array of readPublicAccount() shapes + // (empty when the wallet is closed). `refresh` is advisory — the wallet + // module owns its own sync. + 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; +};