mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(amm): move all AMM logic into the amm_module core module; flip UI to consume it
Introduce modules/amm — the AMM business logic as a universal core Logos module, consumed identically by the QML UI (via modules().amm_module) and headlessly (logoscore call amm_module ...). The module is a thin transport adapter: the domain math lives in the Rust amm_client crate (the transport-independent JSON FFI), and the module sequences those pure ops with chain I/O delegated to the logos_execution_zone wallet module. It reaches the same shared wallet instance the UI opened (Basecamp loads core modules as singletons; standalone the LogosAPI client cache dedups the connection), so it never opens a second wallet. The module owns the full AMM surface — not just swaps: - resolvePool / swapExactInput / tokenList (the swap path) - newPositionContext / quoteNewPosition / submitNewPosition (add-liquidity) apps/amm: delete the app-side orchestration (SwapRuntime, NewPositionRuntime, AmmClient/BundledAmmClient) and the amm_client link. AmmUiBackend now owns only wallet-session lifecycle and forwards every AMM slot to modules().amm_module. The one wallet-keyset mutation add-liquidity needs — creating a fresh LP holding — stays in the backend (via its wallet provider, keeping the account model and on-disk storage coherent): the module returns "requires_fresh_lp" without submitting, the backend creates the account and resubmits with its id. flake.nix / CMakeLists / metadata: the module links the amm_client crate; the UI links no external lib and depends on amm_module (injected into the UI builder's flakeInputs so the dependency resolves). - amounts/deadline declared nlohmann::json so the generated dispatch accepts a JSON number (bare small ints on the CLI) or a string (exact u128 from the UI, or a quote-wrapped big value on the CLI); JSON floats are rejected rather than submit a silently-rounded amount. - AMM_DEBUG-gated tracing for the swap path. - Drop tests/cpp/NewPositionRuntimeTest.cpp with the class it covered (module-level tests to follow). - modules/amm/README.md: architecture, headless prerequisites, logoscore recipe.
This commit is contained in:
+3
-27
@@ -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_client 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()
|
||||
|
||||
+7
-6
@@ -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 .#<name>` — there is no bare
|
||||
`nix run .` default.)
|
||||
Build just the AMM core module with `nix build .#amm-module`, or its underlying
|
||||
client crate with `nix build .#amm_client`. (Each UI is exposed under its own
|
||||
name, so future apps are `nix run .#<name>` — there is no bare `nix run .`
|
||||
default.)
|
||||
|
||||
## Running inside Logos Basecamp
|
||||
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,11 @@ Rectangle {
|
||||
readonly property bool hasAmount: editingSide === "sell" ? parsedSellInput > 0 : parsedBuyInput > 0
|
||||
readonly property bool tokensSelected: sellToken !== null && buyToken !== null
|
||||
readonly property bool insufficientLiquidity: hasAmount && root.poolExists && parsedBuyAmount > buyReserveNum
|
||||
// True only when THIS app's wallet is connected. The backend also enforces
|
||||
// this before submitting (AmmUiBackend::swapExactInput), but gate the UI too
|
||||
// so a disconnected app never even initiates a swap against the shared wallet.
|
||||
readonly property bool walletOpen: root.backend !== null && root.backend.isWalletOpen
|
||||
|
||||
// The backend only exposes swapExactInput, so only the "I know exactly
|
||||
// how much I'm selling" direction can actually be submitted. Editing the
|
||||
// buy field still previews an estimate (via amountInFor above) but can't
|
||||
@@ -175,6 +180,7 @@ Rectangle {
|
||||
&& parsedSellAmount > 0 && parsedBuyAmount > 0
|
||||
&& root.poolResolved && root.poolExists
|
||||
&& !insufficientLiquidity && !root.swapInProgress
|
||||
&& root.walletOpen
|
||||
|
||||
readonly property string submitButtonText: {
|
||||
if (!tokensSelected) return qsTr("Select tokens")
|
||||
@@ -185,6 +191,7 @@ Rectangle {
|
||||
if (!root.poolExists) return qsTr("No pool / no liquidity")
|
||||
if (insufficientLiquidity) return qsTr("Insufficient liquidity")
|
||||
if (parsedBuyAmount <= 0) return qsTr("Amount too small")
|
||||
if (!root.walletOpen) return qsTr("Connect wallet to swap")
|
||||
return qsTr("Swap")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
// 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;
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
#include "AmmClient.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonParseError>
|
||||
|
||||
#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);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
|
||||
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;
|
||||
};
|
||||
+81
-205
@@ -1,46 +1,50 @@
|
||||
#include "AmmUiBackend.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QSettings>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
#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";
|
||||
|
||||
// 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";
|
||||
// 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() },
|
||||
};
|
||||
}
|
||||
|
||||
// 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<LogosModules>(m_logosAPI)),
|
||||
m_wallet(std::make_unique<LogosWalletProvider>(m_logosAPI)),
|
||||
m_walletController(std::make_unique<WalletController>(
|
||||
*m_wallet, QStringLiteral("AmmUI"))),
|
||||
m_ammClient(std::make_unique<BundledAmmClient>()),
|
||||
m_newPosition(std::make_unique<NewPositionRuntime>(m_wallet.get(), m_ammClient.get())),
|
||||
m_swap(std::make_unique<SwapRuntime>(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,39 @@ 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;
|
||||
setNewPositionContext(loadingContext());
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
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<unsigned char>(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());
|
||||
// This app's connected state is the authoritative submit guard. disconnectWallet()
|
||||
// only locks this UI and leaves the shared logos_execution_zone wallet open (another
|
||||
// app may keep it open, or this app opened-then-disconnected), and the QML submit path
|
||||
// doesn't check isWalletOpen — so without this a swap could sign/submit while the UI
|
||||
// shows "Connect". Mirrors the guard the old SwapRuntime::swap() enforced.
|
||||
if (!isWalletOpen())
|
||||
return {};
|
||||
|
||||
const QString txHash = m_logos->amm_module.swapExactInput(
|
||||
defAHex, defBHex, userInputHoldingHex, userOutputHoldingHex,
|
||||
amountInDecimal, minOutDecimal, deadlineDecimal);
|
||||
if (!txHash.isEmpty())
|
||||
refreshBalances();
|
||||
return txHash;
|
||||
@@ -307,59 +237,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();
|
||||
}
|
||||
|
||||
+20
-43
@@ -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_client 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<LogosModules> m_logos;
|
||||
std::unique_ptr<LogosWalletProvider> m_wallet;
|
||||
std::unique_ptr<WalletController> m_walletController;
|
||||
std::unique_ptr<AmmClient> m_ammClient;
|
||||
std::unique_ptr<NewPositionRuntime> m_newPosition;
|
||||
std::unique_ptr<SwapRuntime> 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
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
#include "NewPositionRuntime.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
#include <QScopedValueRollback>
|
||||
#include <libbase58.h>
|
||||
|
||||
#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<char, BASE58_BUFFER_SIZE> encoded {};
|
||||
std::size_t size = encoded.size();
|
||||
if (!b58enc(encoded.data(), &size, bytes.constData(),
|
||||
static_cast<std::size_t>(bytes.size()))) {
|
||||
return {};
|
||||
}
|
||||
return QString::fromLatin1(
|
||||
encoded.data(), static_cast<qsizetype>(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<bool> jsonBoolList(const QJsonArray& values)
|
||||
{
|
||||
QVector<bool> result;
|
||||
result.reserve(values.size());
|
||||
for (const QJsonValue& value : values)
|
||||
result.append(value.toBool());
|
||||
return result;
|
||||
}
|
||||
|
||||
QVector<quint32> jsonUIntList(const QJsonArray& values)
|
||||
{
|
||||
QVector<quint32> result;
|
||||
result.reserve(values.size());
|
||||
for (const QJsonValue& value : values)
|
||||
result.append(static_cast<quint32>(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<WalletAccountRead>& 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<bool> 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<bool> signingRequirements = jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray());
|
||||
const QVector<quint32> 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<qulonglong>(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();
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
|
||||
#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;
|
||||
};
|
||||
@@ -1,169 +0,0 @@
|
||||
#include "SwapRuntime.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
#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<bool> jsonBoolList(const QJsonArray& values)
|
||||
{
|
||||
QVector<bool> result;
|
||||
result.reserve(values.size());
|
||||
for (const QJsonValue& value : values)
|
||||
result.append(value.toBool());
|
||||
return result;
|
||||
}
|
||||
|
||||
QVector<quint32> jsonUIntList(const QJsonArray& values)
|
||||
{
|
||||
QVector<quint32> result;
|
||||
result.reserve(values.size());
|
||||
for (const QJsonValue& value : values)
|
||||
result.append(static_cast<quint32>(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 } };
|
||||
// Attaches a diagnostic code the Swap UI surfaces verbatim (SwapCard.qml:
|
||||
// pool.error). The normal "no pool / no liquidity yet" state stays
|
||||
// code-less — that's the bare `absent` / resolve_pool `{exists:false}`
|
||||
// below, which the UI renders as its neutral "no pool" message, not an error.
|
||||
const auto failure = [](const QString& code) {
|
||||
return QVariantMap {
|
||||
{ QStringLiteral("exists"), false },
|
||||
{ QStringLiteral("error"), code },
|
||||
};
|
||||
};
|
||||
|
||||
// Network still resolving AMM_PROGRAM_BIN: transient startup state, not a
|
||||
// diagnostic — surface nothing so the UI keeps its "loading" affordance.
|
||||
if (network.status != QStringLiteral("ready"))
|
||||
return absent;
|
||||
|
||||
// readConfig returns {} only when the config_id op itself fails (a client
|
||||
// bug, not a chain state) — as opposed to the config account merely being
|
||||
// unreadable, which surfaces as swap_pair's `config_unavailable` below.
|
||||
const QJsonObject config = readConfig(network);
|
||||
if (config.isEmpty())
|
||||
return failure(QStringLiteral("backend_error"));
|
||||
|
||||
const AmmClientResult pairResult = m_client->swapPair(QJsonObject {
|
||||
{ QStringLiteral("ammProgramId"), network.ammProgramId },
|
||||
{ QStringLiteral("tokenInId"), tokenInId },
|
||||
{ QStringLiteral("tokenOutId"), tokenOutId },
|
||||
{ QStringLiteral("config"), config },
|
||||
});
|
||||
if (!pairResult.ok)
|
||||
return failure(QStringLiteral("backend_error"));
|
||||
if (pairResult.value.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) {
|
||||
// swap_pair reports `config_unavailable` when the AMM config account is
|
||||
// missing/uninitialized on this network, `same_token_pair` for an
|
||||
// invalid pair, etc. Propagate its code rather than flattening to
|
||||
// "no pool", so a misconfigured network is distinguishable from an
|
||||
// empty one.
|
||||
const QString code = pairResult.value.value(QStringLiteral("code")).toString();
|
||||
return failure(code.isEmpty() ? QStringLiteral("backend_error") : code);
|
||||
}
|
||||
|
||||
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 failure(QStringLiteral("backend_error"));
|
||||
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;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
|
||||
#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;
|
||||
};
|
||||
@@ -1,230 +0,0 @@
|
||||
#include "AmmClient.h"
|
||||
#include "NewPositionRuntime.h"
|
||||
#include "WalletProvider.h"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -108,19 +108,24 @@
|
||||
}
|
||||
);
|
||||
|
||||
# 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_client 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_client) 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,6 +158,22 @@
|
||||
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_client 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_client = { input = self; packages.default = "amm_client"; };
|
||||
};
|
||||
};
|
||||
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
|
||||
@@ -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" ])
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(AmmModulePlugin LANGUAGES CXX)
|
||||
|
||||
# Include the Logos Module CMake helper (provided by logos-module-builder).
|
||||
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
|
||||
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
||||
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")
|
||||
include(cmake/LogosModule.cmake)
|
||||
else()
|
||||
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
|
||||
endif()
|
||||
|
||||
# Derive the module name from metadata.json — single source of truth.
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/metadata.json" METADATA_JSON)
|
||||
string(JSON MODULE_NAME GET ${METADATA_JSON} name)
|
||||
|
||||
# Universal core module: we write only the impl class; the Qt plugin glue is
|
||||
# generated from src/amm_module_impl.h (because metadata.json sets
|
||||
# "interface": "universal"). EXTERNAL_LIBS links amm_client — the pure-Rust,
|
||||
# transport-independent AMM brain (PDA/decode/quote/plan/encode) exposed as a
|
||||
# JSON FFI (amm_client.h), the same library the UI links. The chain I/O
|
||||
# dependency (logos_execution_zone) is declared in metadata.json and reached via
|
||||
# modules().logos_execution_zone in the impl, so it is NOT listed here.
|
||||
logos_module(
|
||||
NAME ${MODULE_NAME}
|
||||
SOURCES
|
||||
src/amm_module_impl.h
|
||||
src/amm_module_impl.cpp
|
||||
EXTERNAL_LIBS
|
||||
amm_client
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
# AMM core module
|
||||
|
||||
`amm_module` — the AMM business logic as a **headless Logos `core` module**
|
||||
(`interface: "universal"`). It exposes the AMM's on-chain operations so they can
|
||||
be driven identically from the QML UI (`apps/amm`, via the generated
|
||||
`modules().amm_module` caller) and from the CLI (`logoscore call amm_module …`).
|
||||
|
||||
See the [Logos developer guide](https://github.com/logos-co/logos-tutorial/blob/master/logos-developer-guide.md)
|
||||
for the module framework.
|
||||
|
||||
## What it does
|
||||
|
||||
The impl class `AmmModuleImpl` (`src/amm_module_impl.{h,cpp}`) is a **transport
|
||||
adapter**: the AMM domain math lives in the Rust `amm_client` crate (a
|
||||
transport-independent JSON FFI), and this module sequences those pure ops with
|
||||
chain I/O delegated to the `logos_execution_zone` wallet module. Its public
|
||||
methods (the module API is generated from the header) are:
|
||||
|
||||
- `resolvePool(defAHex, defBHex)` — derives the pool PDAs
|
||||
(config/pool/vaults/current-tick) and reads the pool's on-chain reserves.
|
||||
Returns `{ exists: false, error }` when the AMM isn't configured/initialized or
|
||||
the pool has no liquidity.
|
||||
- `swapExactInput(defAHex, defBHex, userInputHoldingHex, userOutputHoldingHex, amountIn, minOut, deadline)`
|
||||
— submits an on-chain `SwapExactInput` transaction (defA = token in,
|
||||
defB = token out); returns the tx hash (or empty on failure). See
|
||||
**Amount / id conventions** below.
|
||||
- `tokenList()` — reads the `TOKENS_CONFIG` JSON array and returns it with
|
||||
`definitionId`/`holding` normalized to hex.
|
||||
- `newPositionContext(request, walletOpen, refreshWalletAccounts)` — the
|
||||
add-liquidity view state (available tokens, fee tiers, warnings) as a
|
||||
`new-position.v1` map.
|
||||
- `quoteNewPosition(request, walletOpen)` — prices an add-liquidity request
|
||||
against current on-chain state (read-only).
|
||||
- `submitNewPosition(request, quoteHash, walletOpen, freshLpId)` — submits an
|
||||
add-liquidity transaction. When the quote needs a fresh LP holding and
|
||||
`freshLpId` is empty, returns `{ status: "requires_fresh_lp" }` **without**
|
||||
submitting: the caller (the app backend, which owns the wallet keyset) creates
|
||||
the account and calls again with its id. Headless callers pre-create an LP
|
||||
holding and pass it.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
QML ──modules().amm_module──┐ ┌── amm_client (Rust cdylib, JSON FFI):
|
||||
CLI ──logoscore call────────┤ │ PDA derivation, account decode,
|
||||
▼ │ quote/plan math, instruction encoding
|
||||
amm_module ───┤ — transport-independent (external_libraries)
|
||||
│ │
|
||||
│ └── logos_execution_zone (dependency):
|
||||
▼ chain reads + tx submit + base58,
|
||||
via modules().logos_execution_zone.*
|
||||
```
|
||||
|
||||
The `amm_client` crate is deliberately I/O-free — each op takes the account data
|
||||
it needs as JSON input and returns a JSON result. This module is the transport
|
||||
adapter the crate is designed to require: it fetches accounts through the wallet
|
||||
module (`get_account_public`, `list_accounts`), hands them to the pure Rust op,
|
||||
and submits the plan the op returns (`send_generic_public_transaction`). It reads
|
||||
the **same** shared wallet instance the UI opened (Basecamp loads core modules as
|
||||
singletons; standalone the LogosAPI client cache dedups the connection), so it
|
||||
never opens a second wallet.
|
||||
|
||||
The impl is deliberately **Qt-free** (`std::string` / `LogosMap` / `LogosList` /
|
||||
`nlohmann::json`), as the universal authoring model requires.
|
||||
|
||||
## Amount / id conventions
|
||||
|
||||
**Account ids are hex**, not base58. The `*Hex` args are parsed as 32-byte hex;
|
||||
a base58 id (what the wallet/runbook display) fails that parse. Convert with
|
||||
`tokenList()` (it emits hex) or `logos_execution_zone.account_id_from_base58 <base58>`.
|
||||
|
||||
**Amounts (`amountIn`/`minOut`, u128) and `deadline` (u64 unix-ms)** are declared
|
||||
`nlohmann::json`, so each accepts **either a JSON number or a decimal string**:
|
||||
|
||||
- **small integer** → pass it bare: `1000`
|
||||
- **big value** (an amount above the JSON/int64 range, e.g. `1e18` base units for
|
||||
an 18-decimal token, **and** the unix-ms deadline, which is always large) →
|
||||
pass it as a **quote-wrapped string**: `'"1000000000000000000"'`
|
||||
|
||||
Why the split: `logoscore` promotes any bare number past ~2³¹ to a JSON *double*,
|
||||
which can't hold a large integer exactly. The module therefore **rejects JSON
|
||||
floats** (rather than submit a silently-rounded amount) and requires big values
|
||||
as strings, which are bit-exact. A quoted CLI arg (`'"…"'`) reaches the module
|
||||
with the quotes folded into the value; the string branch strips that wrapper.
|
||||
The UI passes `QString` (→ `QVariant` → string branch) and is unaffected.
|
||||
|
||||
## Build
|
||||
|
||||
Built from the **repo-root** flake (which provides the `amm_client` library it
|
||||
links):
|
||||
|
||||
```bash
|
||||
nix build .#amm-module
|
||||
# output: result/lib/amm_module_plugin.dylib (+ libamm_client.dylib)
|
||||
```
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
Both are absolute-path env vars set on the **process that hosts the module**
|
||||
(the `logoscore` daemon, or Basecamp) — not on the `call`:
|
||||
|
||||
- `AMM_PROGRAM_BIN` — the deployed `amm.bin`. Required; its ELF determines the
|
||||
program id and every derived PDA. Without it, `resolvePool` returns
|
||||
`{ exists: false, error: "no_program_bin" }`.
|
||||
- `TOKENS_CONFIG` — JSON array of `{ symbol, name, definitionId, holding, decimals }`
|
||||
consumed by `tokenList()`.
|
||||
|
||||
## Headless usage with `logoscore`
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Have all of the following in place before staging the modules dir:
|
||||
|
||||
1. **Nix** with flakes enabled (same as the rest of the repo).
|
||||
|
||||
2. **The runtime CLIs** (from their own flakes, per the developer guide):
|
||||
|
||||
```bash
|
||||
nix profile install 'github:logos-co/logos-logoscore-cli' # logoscore (daemon + client)
|
||||
nix build 'github:logos-co/logos-module#lm' # lm (static plugin inspector, optional)
|
||||
```
|
||||
|
||||
`lm` introspects a built plugin without running it — handy to confirm the API
|
||||
(`lm result/lib/amm_module_plugin.dylib` shows methods, signatures, deps).
|
||||
|
||||
3. **This module, built** (produces `amm_module_plugin.dylib` + `libamm_client.dylib`):
|
||||
|
||||
```bash
|
||||
nix build .#amm-module # from the repo root; output under result/lib/
|
||||
```
|
||||
|
||||
4. **The wallet module it depends on, built** — `logos_execution_zone` is a
|
||||
*separate repo*, not part of this tree. Build the **same rev** this module
|
||||
pins as its `logos_execution_zone` flake input (mismatched revs = ABI/ImageID
|
||||
drift), producing `logos_execution_zone_plugin.dylib` + `libwallet_ffi.dylib`:
|
||||
|
||||
```bash
|
||||
nix build 'github:gravityblast/logos-execution-zone-module?ref=fix/generic-tx-instruction-bstr'
|
||||
# output under result/lib/ — copy it aside before building amm-module (both use ./result)
|
||||
```
|
||||
|
||||
5. **The deployed `amm.bin`** for `AMM_PROGRAM_BIN` — the exact binary running on
|
||||
your target sequencer (its ELF fixes the program id and every PDA). See
|
||||
`apps/amm/README.md` and the testnet runbook.
|
||||
|
||||
6. **A tokens config** for `TOKENS_CONFIG` — a JSON array of
|
||||
`{ symbol, name, definitionId, holding, decimals }` (e.g. the repo's
|
||||
`amm-tokens.json`).
|
||||
|
||||
7. **A wallet** at `~/.lee/wallet` (`wallet_config.json` with `sequencer_addr`
|
||||
pointing at your sequencer, plus `storage.json` with your accounts), and a
|
||||
**running sequencer** with the AMM initialized and a pool holding liquidity.
|
||||
`tokenList` reads `TOKENS_CONFIG` from disk and needs no wallet, but every
|
||||
other op (including `resolvePool`) reads on-chain through the wallet module's
|
||||
`get_account_public`, which needs the wallet **open** (the handle is null
|
||||
until `open`/`create_new`); `swapExactInput` additionally needs it **synced**
|
||||
(see below).
|
||||
|
||||
### Staging the modules directory
|
||||
|
||||
**Core modules get no `.lgx` from the builder** (only UI modules do), so stage a
|
||||
modules directory by hand — one subdir per module, each with `manifest.json` +
|
||||
`variant` + the plugin dylib (and its sibling FFI dylib, since rpath is
|
||||
`@loader_path`). The daemon discovers modules from this layout; it does **not**
|
||||
verify the manifest hashes at load time.
|
||||
|
||||
```
|
||||
modules/
|
||||
amm_module/
|
||||
amm_module_plugin.dylib
|
||||
libamm_client.dylib
|
||||
variant # one line: darwin-arm64-dev
|
||||
manifest.json
|
||||
logos_execution_zone/
|
||||
logos_execution_zone_plugin.dylib
|
||||
libwallet_ffi.dylib
|
||||
variant
|
||||
manifest.json
|
||||
```
|
||||
|
||||
`amm_module/manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "amm_module", "type": "core", "version": "0.1.0",
|
||||
"manifestVersion": "0.2.0", "dependencies": ["logos_execution_zone"],
|
||||
"main": { "darwin-arm64-dev": "amm_module_plugin.dylib" }
|
||||
}
|
||||
```
|
||||
|
||||
Start the daemon **with the env vars set on it**, then load the dependency
|
||||
first, then the module:
|
||||
|
||||
```bash
|
||||
AMM_PROGRAM_BIN=/abs/path/to/amm.bin \
|
||||
TOKENS_CONFIG=/abs/path/to/amm-tokens.json \
|
||||
logoscore -D -m ./modules --persistence-path ./data
|
||||
|
||||
logoscore load-module logos_execution_zone # dependency first
|
||||
logoscore load-module amm_module
|
||||
```
|
||||
|
||||
`tokenList` reads `TOKENS_CONFIG` from disk — no wallet needed:
|
||||
|
||||
```bash
|
||||
logoscore call amm_module tokenList
|
||||
```
|
||||
|
||||
Every other op reads on-chain through the wallet module's `get_account_public`,
|
||||
which fails on a null wallet handle (surfacing as an absent pool), so open the
|
||||
wallet first — `resolvePool` then works:
|
||||
|
||||
```bash
|
||||
logoscore call logos_execution_zone open ~/.lee/wallet/wallet_config.json ~/.lee/wallet/storage.json
|
||||
logoscore call amm_module resolvePool <defA_hex> <defB_hex>
|
||||
```
|
||||
|
||||
`swapExactInput` reuses that open wallet but additionally needs it **synced**
|
||||
(nothing opens/syncs it for you headlessly). Note the amount/deadline
|
||||
conventions above — small amount bare, deadline (and any big amount) quoted:
|
||||
|
||||
```bash
|
||||
logoscore call amm_module swapExactInput \
|
||||
<defA_hex> <defB_hex> <inputHolding_hex> <outputHolding_hex> \
|
||||
1000 1 '"32503680000000"'
|
||||
# big amount: replace 1000 with '"1000000000000000000"'
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Set `AMM_DEBUG=1` on the daemon to trace every `swapExactInput` step (parsed
|
||||
args, the assembled account list, and the raw `send_generic_public_transaction`
|
||||
reply) to the module host's stderr, which the daemon captures in its log. A
|
||||
failed swap returns an empty tx hash; the trace shows the reason.
|
||||
|
||||
### Wallet sync gotcha
|
||||
|
||||
`get_balance` reads the wallet's *local synced state*, and `swapExactInput`
|
||||
builds transactions against it. If you reset/reinitialize the sequencer, the
|
||||
wallet's `storage.json` may keep a stale `last_synced_block` ahead of the new
|
||||
chain — transactions then reference dead state and the sequencer rejects them
|
||||
(reserves don't move). Reset the cursor (`last_synced_block: 0`, keep
|
||||
`key_chain`/`labels`) and re-`open` + `sync_to_block <height>` to re-sync from
|
||||
genesis. `resolvePool` is a **live** sequencer read, so the stale cursor doesn't
|
||||
affect it — but it still needs the wallet **open**: the read goes through the
|
||||
wallet's sequencer connection (not its private keys), which only exists once the
|
||||
wallet is opened.
|
||||
|
||||
## Install into Basecamp
|
||||
|
||||
`amm_module` is a **core** module (installed into `modules/`, alongside the
|
||||
wallet module), not a UI plugin. Build its `.lgx` from the root flake and
|
||||
install with `lgpm --modules-dir …` (see `apps/amm/README.md` for the full
|
||||
three-package flow: `logos_execution_zone` + `amm_module` + the `amm_ui` UI
|
||||
plugin).
|
||||
|
||||
## QtRO / byte-string note
|
||||
|
||||
`send_generic_public_transaction` takes `instruction` as a byte string
|
||||
(`std::vector<uint8_t>`). This module sends the plan's u32 words as their
|
||||
little-endian bytes and references the program by its id hex (not the raw ELF).
|
||||
It requires the wallet module built with the byte-string `instruction` param —
|
||||
the fork pinned as the `logos_execution_zone` input. See
|
||||
`docs/amm-swap-qtro-serialization-bug.md`.
|
||||
|
||||
## Known follow-ups
|
||||
|
||||
- **`swapExactOutput` is not exposed yet.** The on-chain program supports it
|
||||
(`amm_core::Instruction::SwapExactOutput`, identical account layout to
|
||||
`SwapExactInput`), but the client path was only ever built for exact-input:
|
||||
`amm_client` has no exact-output op and neither the UI nor this module has a
|
||||
`swapExactOutput` method. Adding it is a near-copy of the exact-input path — an
|
||||
`amm_swap_exact_output_*` op in the crate plus a `swapExactOutput` method here.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
description = "Logos AMM core module — headless AMM business logic (pool resolution + swaps)";
|
||||
|
||||
inputs = {
|
||||
logos-module-builder.url = "github:logos-co/logos-module-builder";
|
||||
|
||||
# Core wallet module dependency. The input name must match the
|
||||
# metadata.json `dependencies` entry so the builder resolves it as a module
|
||||
# dependency. Same fork the repo-root flake pins (the QtRO byte-string
|
||||
# `instruction` fix for send_generic_public_transaction).
|
||||
logos_execution_zone.url = "github:gravityblast/logos-execution-zone-module?ref=fix/generic-tx-instruction-bstr";
|
||||
};
|
||||
|
||||
# NOTE: like apps/amm, this flake is NOT built standalone. The amm_client
|
||||
# crate this module links (the Rust JSON-FFI brain) lives in the repo-root
|
||||
# flake, and referencing it from here would require a hardcoded `git+file://`
|
||||
# path or a `path:../..` input — the latter fails flake evaluation because this
|
||||
# dir is copied into the Nix store as its own flake root, so `../..` can't
|
||||
# escape it. Instead, the repo-root flake.nix builds this module directly
|
||||
# (src = ./modules/amm) and resolves amm_client via `self`. Build it from
|
||||
# the repo root:
|
||||
# nix build .#amm-module
|
||||
outputs = inputs@{ logos-module-builder, ... }:
|
||||
logos-module-builder.lib.mkLogosModule {
|
||||
src = ./.;
|
||||
configFile = ./metadata.json;
|
||||
flakeInputs = inputs;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "amm_module",
|
||||
"version": "0.1.0",
|
||||
"type": "core",
|
||||
"interface": "universal",
|
||||
"category": "amm",
|
||||
"description": "AMM business logic — on-chain pool resolution and swaps",
|
||||
"main": "amm_module_plugin",
|
||||
"dependencies": ["logos_execution_zone"],
|
||||
|
||||
"nix": {
|
||||
"packages": {
|
||||
"build": [],
|
||||
"runtime": []
|
||||
},
|
||||
"external_libraries": [
|
||||
{ "name": "amm_client" }
|
||||
],
|
||||
"cmake": {
|
||||
"find_packages": [],
|
||||
"extra_sources": [],
|
||||
"extra_include_dirs": [],
|
||||
"extra_link_libraries": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
#include "amm_module_impl.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
// Generated at build time by logos-cpp-generator. Defines `LogosModules` with
|
||||
// one std-typed accessor per metadata.json dependency — here
|
||||
// `logos_execution_zone`. Included only in the .cpp so the impl header the
|
||||
// generator parses stays free of Qt and codegen types.
|
||||
#include "logos_sdk.h"
|
||||
|
||||
extern "C" {
|
||||
#include "amm_client.h"
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
// AMM_DEBUG-gated tracing. The module runs in its own logos_host process whose
|
||||
// stderr the daemon captures, so these lines surface in the daemon log.
|
||||
bool ammDebug() {
|
||||
static const bool on = std::getenv("AMM_DEBUG") != nullptr;
|
||||
return on;
|
||||
}
|
||||
#define AMM_TRACE(msg) \
|
||||
do { \
|
||||
if (ammDebug()) std::cerr << "[amm-debug] " << msg << std::endl; \
|
||||
} while (0)
|
||||
|
||||
// Absolute path to the deployed AMM program's compiled binary (amm.bin). The
|
||||
// module can't derive this itself: the wallet module's bundled AMM program may
|
||||
// differ from whatever is deployed on the target sequencer, and the bytes are
|
||||
// what determine the program id (and every PDA derived from it).
|
||||
constexpr char AMM_PROGRAM_BIN_ENV[] = "AMM_PROGRAM_BIN";
|
||||
|
||||
// Absolute path to the JSON token-list config consumed by tokenList().
|
||||
constexpr char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG";
|
||||
|
||||
// new-position.v1 response schema tag (matches the app-side NewPositionRuntime
|
||||
// and the Rust client's NEW_POSITION_SCHEMA).
|
||||
constexpr char SCHEMA[] = "new-position.v1";
|
||||
|
||||
int hexVal(char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// True when `s` is exactly `len` hex digits (case-insensitive).
|
||||
bool isHexLen(const std::string& s, size_t len) {
|
||||
if (s.size() != len) return false;
|
||||
for (const char c : s)
|
||||
if (hexVal(c) < 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// True when `s` is an even-length run of hex digits (case-insensitive).
|
||||
bool isHexEven(const std::string& s) {
|
||||
if (s.size() % 2 != 0) return false;
|
||||
for (const char c : s)
|
||||
if (hexVal(c) < 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string toHex(const uint8_t* p, size_t n) {
|
||||
static const char* const kDigits = "0123456789abcdef";
|
||||
std::string s;
|
||||
s.reserve(n * 2);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
s.push_back(kDigits[p[i] >> 4]);
|
||||
s.push_back(kDigits[p[i] & 0x0f]);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Exception-safe field accessor over a json object: returns "" when the key is
|
||||
// missing or not a string (nlohmann's value()/get() throw on a type mismatch).
|
||||
std::string jStr(const json& obj, const char* key) {
|
||||
const auto it = obj.find(key);
|
||||
return (it != obj.end() && it->is_string()) ? it->get<std::string>() : std::string();
|
||||
}
|
||||
|
||||
// Milliseconds since the unix epoch (u64). Used for the plan's `nowMs` and the
|
||||
// client deadline check — the module runs on the host, not in the zkVM, so wall
|
||||
// clock is available (unlike a guest).
|
||||
uint64_t nowMs() {
|
||||
return static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count());
|
||||
}
|
||||
|
||||
// Decimal string -> u64. False (leaving `out` unset) on empty, non-digit, or
|
||||
// overflow.
|
||||
bool parseU64(const std::string& s, uint64_t& out) {
|
||||
if (s.empty()) return false;
|
||||
uint64_t value = 0;
|
||||
for (const char c : s) {
|
||||
if (c < '0' || c > '9') return false;
|
||||
const uint64_t d = static_cast<uint64_t>(c - '0');
|
||||
if (value > (~static_cast<uint64_t>(0) - d) / 10) return false;
|
||||
value = value * 10 + d;
|
||||
}
|
||||
out = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Coerce a swap amount arg — arriving as EITHER a JSON number (bare `1000` on
|
||||
// the CLI) or a decimal string ("1000", a big u128 the UI passes, or a
|
||||
// quote-wrapped big value on the CLI) — to its canonical decimal-string form.
|
||||
// Small values fit a JSON integer; big values (amounts above the JSON/int64
|
||||
// range, and unix-ms deadlines) must be strings. Returns false for JSON floats,
|
||||
// negatives, or non-numeric strings, leaving `out` unset.
|
||||
bool jsonAmountToDecimal(const json& j, std::string& out) {
|
||||
if (j.is_string()) {
|
||||
std::string s = j.get<std::string>();
|
||||
// Trim whitespace, then a single pair of wrapping double quotes if
|
||||
// present. This is what lets an EXACT u128 above 2^53 be passed from the
|
||||
// logoscore CLI: such a value can't be a JSON number (a bare number that
|
||||
// large is a lossy double), so it must be a string — but the CLI folds a
|
||||
// quoted arg's quotes INTO the value (`"1e18"` arrives as the literal
|
||||
// chars "\"1e18\""). Stripping the wrapper recovers the clean digits. A
|
||||
// clean string from the UI (no wrapping quotes) is unaffected.
|
||||
auto trim = [](std::string& x) {
|
||||
const size_t a = x.find_first_not_of(" \t\n\r");
|
||||
const size_t b = x.find_last_not_of(" \t\n\r");
|
||||
x = (a == std::string::npos) ? std::string() : x.substr(a, b - a + 1);
|
||||
};
|
||||
trim(s);
|
||||
if (s.size() >= 2 && s.front() == '"' && s.back() == '"') {
|
||||
s = s.substr(1, s.size() - 2);
|
||||
trim(s);
|
||||
}
|
||||
out = s;
|
||||
return true;
|
||||
}
|
||||
if (j.is_number_unsigned()) {
|
||||
out = std::to_string(j.get<uint64_t>());
|
||||
return true;
|
||||
}
|
||||
if (j.is_number_integer()) {
|
||||
const int64_t v = j.get<int64_t>();
|
||||
if (v < 0) return false;
|
||||
out = std::to_string(v);
|
||||
return true;
|
||||
}
|
||||
// Reject JSON floats (and null/object/array). logoscore promotes any bare
|
||||
// number beyond ~2^31 to a double, so a large bare value arrives here as a
|
||||
// float — already rounded upstream. Rather than silently accept it, require
|
||||
// such big numbers as a (quote-wrapped) STRING, which is bit-exact.
|
||||
return false;
|
||||
}
|
||||
|
||||
// json string/bool arrays -> std vectors, and a u32-word array -> the
|
||||
// little-endian bytes the wallet module's byte-string `instruction` expects.
|
||||
std::vector<std::string> jsonStrVec(const json& arr) {
|
||||
std::vector<std::string> out;
|
||||
if (!arr.is_array()) return out;
|
||||
out.reserve(arr.size());
|
||||
for (const auto& v : arr)
|
||||
if (v.is_string()) out.push_back(v.get<std::string>());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<bool> jsonBoolVec(const json& arr) {
|
||||
std::vector<bool> out;
|
||||
if (!arr.is_array()) return out;
|
||||
out.reserve(arr.size());
|
||||
for (const auto& v : arr)
|
||||
out.push_back(v.is_boolean() && v.get<bool>());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> jsonWordsToLeBytes(const json& arr) {
|
||||
std::vector<uint8_t> out;
|
||||
if (!arr.is_array()) return out;
|
||||
out.reserve(arr.size() * sizeof(uint32_t));
|
||||
for (const auto& v : arr) {
|
||||
const uint32_t word = v.is_number() ? static_cast<uint32_t>(v.get<uint64_t>()) : 0;
|
||||
out.push_back(static_cast<uint8_t>(word & 0xff));
|
||||
out.push_back(static_cast<uint8_t>((word >> 8) & 0xff));
|
||||
out.push_back(static_cast<uint8_t>((word >> 16) & 0xff));
|
||||
out.push_back(static_cast<uint8_t>((word >> 24) & 0xff));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Result of an amm_client JSON op: the `{ ok, value }` envelope decoded.
|
||||
struct FfiResult {
|
||||
bool ok = false;
|
||||
json value;
|
||||
};
|
||||
|
||||
// Serialize `request`, hand it to an amm_client op, and decode its
|
||||
// `{ ok, value, error }` envelope. Mirrors apps/amm/src/AmmClient.cpp. `value`
|
||||
// is only populated (and `ok` true) when the op reports success with an object.
|
||||
FfiResult call(char* (*op)(const char*), const json& request) {
|
||||
const std::string payload = request.dump();
|
||||
char* raw = op(payload.c_str());
|
||||
if (raw == nullptr) {
|
||||
AMM_TRACE("amm_client op returned null");
|
||||
return {};
|
||||
}
|
||||
const std::string response(raw);
|
||||
amm_free(raw);
|
||||
|
||||
const auto doc = json::parse(response, nullptr, /*allow_exceptions=*/false);
|
||||
if (!doc.is_object()) {
|
||||
AMM_TRACE("amm_client op returned invalid JSON");
|
||||
return {};
|
||||
}
|
||||
if (!doc.value("ok", false)) {
|
||||
AMM_TRACE("amm_client op failure: " << doc.value("error", std::string()));
|
||||
return {};
|
||||
}
|
||||
const auto it = doc.find("value");
|
||||
if (it == doc.end() || !it->is_object()) {
|
||||
AMM_TRACE("amm_client op value is not an object");
|
||||
return {};
|
||||
}
|
||||
return {true, *it};
|
||||
}
|
||||
|
||||
// new-position.v1 envelope builders (ported from NewPositionRuntime).
|
||||
json issue(const std::string& code, const json& blockingFields = json::array()) {
|
||||
return {
|
||||
{"code", code},
|
||||
{"recoverable", true},
|
||||
{"blockingFields", blockingFields},
|
||||
{"details", json::object()},
|
||||
};
|
||||
}
|
||||
|
||||
json publicError(const std::string& code,
|
||||
const json& blockingFields = json::array(),
|
||||
const json& details = json::object()) {
|
||||
json error = issue(code, blockingFields);
|
||||
error["details"] = details;
|
||||
return {
|
||||
{"schema", SCHEMA},
|
||||
{"status", "error"},
|
||||
{"canSubmit", false},
|
||||
{"code", code},
|
||||
{"errors", json::array({error})},
|
||||
{"warnings", json::array()},
|
||||
{"accountPreview", json::array()},
|
||||
};
|
||||
}
|
||||
|
||||
json contextState(const std::string& status,
|
||||
const std::string& network_id,
|
||||
const std::string& network_fingerprint,
|
||||
const std::string& code = {}) {
|
||||
json state = {
|
||||
{"schema", SCHEMA},
|
||||
{"status", status},
|
||||
{"networkId", network_id},
|
||||
{"networkFingerprint", network_fingerprint},
|
||||
{"tokens", json::array()},
|
||||
{"feeTiers", json::array()},
|
||||
{"warnings", json::array()},
|
||||
};
|
||||
if (!code.empty()) state["code"] = code;
|
||||
return state;
|
||||
}
|
||||
|
||||
// A json array of the strings at `obj[key]` (empty array when absent/wrong type).
|
||||
json stringArray(const json& obj, const char* key) {
|
||||
const auto it = obj.find(key);
|
||||
if (it == obj.end() || !it->is_array()) return json::array();
|
||||
json out = json::array();
|
||||
for (const auto& v : *it)
|
||||
if (v.is_string()) out.push_back(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<uint8_t> AmmModuleImpl::loadAmmElf() {
|
||||
const char* path = std::getenv(AMM_PROGRAM_BIN_ENV);
|
||||
if (path == nullptr || *path == '\0') return {};
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file) return {};
|
||||
return std::vector<uint8_t>((std::istreambuf_iterator<char>(file)),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
std::string AmmModuleImpl::ammProgramId() {
|
||||
const std::vector<uint8_t> elf = loadAmmElf();
|
||||
if (elf.empty()) return {};
|
||||
// Hand the deployed binary to the amm_client program_id op, which decodes it
|
||||
// and computes the Image ID — 64-char lowercase hex, little-endian per u32
|
||||
// word (matches `spel program-id` and the on-chain *_program_id fields).
|
||||
const FfiResult r = call(amm_program_id, json{{"elf", toHex(elf.data(), elf.size())}});
|
||||
if (!r.ok) {
|
||||
AMM_TRACE("ammProgramId: amm_program_id op failed");
|
||||
return {};
|
||||
}
|
||||
return jStr(r.value, "programId");
|
||||
}
|
||||
|
||||
AmmModuleImpl::Network AmmModuleImpl::network() {
|
||||
// AMM_PROGRAM_BIN / TOKENS_CONFIG are fixed for the process lifetime and this
|
||||
// runs on the hot reply path, so resolve the program id + token ids once.
|
||||
if (!networkResolved) {
|
||||
const std::string id = ammProgramId();
|
||||
if (id.empty()) {
|
||||
// Not resolvable yet (AMM_PROGRAM_BIN unset/unreadable). Don't cache a
|
||||
// transient miss — a later call retries.
|
||||
Network net;
|
||||
net.status = "config_missing";
|
||||
return net;
|
||||
}
|
||||
programId = id;
|
||||
tokenIds.clear();
|
||||
for (const auto& token : tokenList()) {
|
||||
const std::string token_id = jStr(token, "definitionId");
|
||||
if (!token_id.empty()) tokenIds.push_back(token_id);
|
||||
}
|
||||
networkResolved = true;
|
||||
}
|
||||
|
||||
Network net;
|
||||
net.amm_program_id = programId;
|
||||
// The program id changes per deployment, so it doubles as the network
|
||||
// fingerprint (a quote can't be replayed against a different program).
|
||||
net.fingerprint = programId;
|
||||
net.token_ids = tokenIds;
|
||||
net.status = "ready";
|
||||
return net;
|
||||
}
|
||||
|
||||
std::string AmmModuleImpl::normalizeAccountId(const std::string& id) {
|
||||
size_t start = 0;
|
||||
size_t end = id.size();
|
||||
while (start < end && std::isspace(static_cast<unsigned char>(id[start]))) ++start;
|
||||
while (end > start && std::isspace(static_cast<unsigned char>(id[end - 1]))) --end;
|
||||
std::string t = id.substr(start, end - start);
|
||||
|
||||
if (isHexLen(t, 64)) {
|
||||
std::transform(t.begin(), t.end(), t.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return t;
|
||||
}
|
||||
|
||||
// Try base58 -> hex via the wallet module ("" on failure).
|
||||
std::string hex = modules().logos_execution_zone.account_id_from_base58(t);
|
||||
std::transform(hex.begin(), hex.end(), hex.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return hex;
|
||||
}
|
||||
|
||||
nlohmann::json AmmModuleImpl::readPublicAccount(const std::string& account_id) {
|
||||
// Matches the app-side accountReadJson: WalletAccountRead defaults status to
|
||||
// "read_failed" and only flips to "ok" when every field is well-formed hex;
|
||||
// the `account` key is present only for an ok read.
|
||||
json result = {{"id", account_id}, {"status", "read_failed"}};
|
||||
|
||||
const std::string account_json =
|
||||
modules().logos_execution_zone.get_account_public(account_id);
|
||||
const auto obj = json::parse(account_json, nullptr, /*allow_exceptions=*/false);
|
||||
if (!obj.is_object()) return result;
|
||||
|
||||
const std::string owner = jStr(obj, "program_owner");
|
||||
const std::string balance = jStr(obj, "balance");
|
||||
const std::string nonce = jStr(obj, "nonce");
|
||||
const std::string data = jStr(obj, "data");
|
||||
if (!isHexLen(owner, 64) || !isHexLen(balance, 32) || !isHexLen(nonce, 32)
|
||||
|| !isHexEven(data)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result["status"] = "ok";
|
||||
result["account"] = {
|
||||
{"program_owner", owner},
|
||||
{"balance", balance},
|
||||
{"nonce", nonce},
|
||||
{"data", data},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
nlohmann::json AmmModuleImpl::walletAccountReads(bool wallet_open, bool refresh) {
|
||||
if (!wallet_open) {
|
||||
walletAccounts = json(); // invalidate — nothing to read while closed
|
||||
return json::array();
|
||||
}
|
||||
// Each readPublicAccount is a live sequencer round-trip, so serve the cached
|
||||
// set unless the caller forces a reload (submit / explicit UI refresh).
|
||||
if (!refresh && !walletAccounts.is_null())
|
||||
return walletAccounts;
|
||||
|
||||
// Normalize the wallet module's [any] return (a vector or a json array)
|
||||
// through json so we can iterate/type-check it uniformly.
|
||||
json accounts = modules().logos_execution_zone.list_accounts();
|
||||
if (!accounts.is_array())
|
||||
return json::array(); // transient — don't cache
|
||||
|
||||
json out = json::array();
|
||||
for (const auto& entry : accounts) {
|
||||
if (!entry.is_object()) continue;
|
||||
// The AMM path uses only public accounts (LP holdings / token balances).
|
||||
if (!entry.value("is_public", true)) continue;
|
||||
const std::string id = jStr(entry, "account_id");
|
||||
if (id.empty()) continue;
|
||||
out.push_back(readPublicAccount(id));
|
||||
}
|
||||
walletAccounts = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
nlohmann::json AmmModuleImpl::readConfig(const Network& net) {
|
||||
const FfiResult configResult =
|
||||
call(amm_config_id, json{{"ammProgramId", net.amm_program_id}});
|
||||
if (!configResult.ok) return json(); // null: config_id op failed
|
||||
return readPublicAccount(jStr(configResult.value, "configId"));
|
||||
}
|
||||
|
||||
LogosMap AmmModuleImpl::resolvePool(const std::string& def_a_hex,
|
||||
const std::string& def_b_hex) {
|
||||
// A hard failure carries a stable `error` code (no_program_bin /
|
||||
// amm_not_initialized / bad_config) so SwapCard can surface it via poolError.
|
||||
// `no_pool` is the ordinary "no pool / no liquidity yet" state, which SwapCard
|
||||
// treats as its normal empty state (SwapCard.qml `error !== "no_pool"`). The
|
||||
// underlying FFI error string is AMM_TRACE'd to the daemon log.
|
||||
auto failed = [](const std::string& error) {
|
||||
return LogosMap{{"exists", false}, {"error", error}};
|
||||
};
|
||||
|
||||
const Network net = network();
|
||||
if (net.status != "ready")
|
||||
// config_missing == no program id from AMM_PROGRAM_BIN (unset/unreadable/bad).
|
||||
return failed("no_program_bin");
|
||||
|
||||
const json config = readConfig(net);
|
||||
if (config.is_null())
|
||||
return failed("bad_config"); // amm_config_id op failed (malformed program id)
|
||||
|
||||
const FfiResult pairResult = call(amm_swap_pair, json{
|
||||
{"ammProgramId", net.amm_program_id},
|
||||
{"tokenInId", def_a_hex},
|
||||
{"tokenOutId", def_b_hex},
|
||||
{"config", config},
|
||||
});
|
||||
if (!pairResult.ok)
|
||||
return failed("bad_config"); // FFI op failed (e.g. bad token-id hex)
|
||||
if (jStr(pairResult.value, "status") != "ok") {
|
||||
// config_unavailable (undecodable config) surfaces as amm_not_initialized;
|
||||
// same_token_pair is passed through as-is.
|
||||
const std::string code = jStr(pairResult.value, "code");
|
||||
if (code == "config_unavailable")
|
||||
return failed("amm_not_initialized");
|
||||
return failed(code.empty() ? "bad_config" : code);
|
||||
}
|
||||
|
||||
const json pool = readPublicAccount(jStr(pairResult.value, "poolId"));
|
||||
const FfiResult resolveResult = call(amm_resolve_pool, json{{"pool", pool}});
|
||||
if (!resolveResult.ok)
|
||||
return failed("bad_config"); // amm_resolve_pool op failed
|
||||
// resolve_pool returns { exists:false } (no error) for a missing pool / no
|
||||
// liquidity; re-tag it "no_pool" — the code SwapCard expects for that state.
|
||||
const json resolved = resolveResult.value;
|
||||
if (!resolved.value("exists", false))
|
||||
return failed("no_pool");
|
||||
return resolved; // { exists:true, reserveA, reserveB, feeBps }
|
||||
}
|
||||
|
||||
std::string AmmModuleImpl::swapExactInput(const std::string& def_a_hex,
|
||||
const std::string& def_b_hex,
|
||||
const std::string& user_input_holding_hex,
|
||||
const std::string& user_output_holding_hex,
|
||||
const nlohmann::json& amount_in,
|
||||
const nlohmann::json& min_out,
|
||||
const nlohmann::json& deadline) {
|
||||
std::string amount_in_decimal;
|
||||
std::string min_out_decimal;
|
||||
std::string deadline_decimal;
|
||||
if (!jsonAmountToDecimal(amount_in, amount_in_decimal)
|
||||
|| !jsonAmountToDecimal(min_out, min_out_decimal)
|
||||
|| !jsonAmountToDecimal(deadline, deadline_decimal)) {
|
||||
AMM_TRACE("swapExactInput: FAIL amount/deadline not a number or decimal string");
|
||||
return {};
|
||||
}
|
||||
|
||||
const Network net = network();
|
||||
if (net.status != "ready") {
|
||||
AMM_TRACE("swapExactInput: FAIL network not ready (" << net.status << ")");
|
||||
return {};
|
||||
}
|
||||
|
||||
const json config = readConfig(net);
|
||||
if (config.is_null()) {
|
||||
AMM_TRACE("swapExactInput: FAIL config_id op failed");
|
||||
return {};
|
||||
}
|
||||
|
||||
// amm_swap_plan resolves the pool, reorders holdings to the pool's canonical
|
||||
// def order, encodes SwapExactInput, and returns a ready-to-submit plan.
|
||||
const FfiResult planResult = call(amm_swap_plan, json{
|
||||
{"ammProgramId", net.amm_program_id},
|
||||
{"tokenInId", def_a_hex},
|
||||
{"tokenOutId", def_b_hex},
|
||||
{"config", config},
|
||||
{"userInputHoldingId", user_input_holding_hex},
|
||||
{"userOutputHoldingId", user_output_holding_hex},
|
||||
{"amountIn", amount_in_decimal},
|
||||
{"minOut", min_out_decimal},
|
||||
{"deadlineMs", deadline_decimal},
|
||||
});
|
||||
if (!planResult.ok || jStr(planResult.value, "status") != "ready") {
|
||||
AMM_TRACE("swapExactInput: FAIL amm_swap_plan not ready");
|
||||
return {};
|
||||
}
|
||||
const json plan = planResult.value;
|
||||
|
||||
const std::vector<std::string> accounts = jsonStrVec(plan.value("accountIds", json::array()));
|
||||
const std::vector<bool> signers = jsonBoolVec(plan.value("signingRequirements", json::array()));
|
||||
const std::vector<uint8_t> instruction = jsonWordsToLeBytes(plan.value("instruction", json::array()));
|
||||
const std::string program_id = jStr(plan, "programId");
|
||||
|
||||
AMM_TRACE("swapExactInput: SUBMIT programId=" << program_id
|
||||
<< " instrBytes=" << instruction.size() << " accounts=" << accounts.size());
|
||||
|
||||
const std::string reply = modules().logos_execution_zone.send_generic_public_transaction(
|
||||
accounts, signers, instruction, program_id);
|
||||
AMM_TRACE("swapExactInput: tx reply=" << reply);
|
||||
|
||||
const auto obj = json::parse(reply, nullptr, /*allow_exceptions=*/false);
|
||||
if (!obj.is_object() || !obj.value("success", false)) {
|
||||
AMM_TRACE("swapExactInput: FAIL tx not successful");
|
||||
return {};
|
||||
}
|
||||
return jStr(obj, "tx_hash");
|
||||
}
|
||||
|
||||
LogosList AmmModuleImpl::tokenList() {
|
||||
LogosList out = LogosList::array();
|
||||
|
||||
const char* path = std::getenv(TOKENS_CONFIG_ENV);
|
||||
if (path == nullptr || *path == '\0') return out;
|
||||
|
||||
std::ifstream file(path);
|
||||
if (!file) return out;
|
||||
const std::string content((std::istreambuf_iterator<char>(file)),
|
||||
std::istreambuf_iterator<char>());
|
||||
|
||||
const auto arr = json::parse(content, nullptr, /*allow_exceptions=*/false);
|
||||
if (!arr.is_array()) return out;
|
||||
|
||||
for (const auto& entry : arr) {
|
||||
if (!entry.is_object()) continue;
|
||||
|
||||
// definitionId/holding may be base58 or hex — normalize both to
|
||||
// lowercase hex so downstream consumers can assume hex.
|
||||
const std::string definition_id = normalizeAccountId(jStr(entry, "definitionId"));
|
||||
const std::string holding = normalizeAccountId(jStr(entry, "holding"));
|
||||
if (definition_id.empty() || holding.empty()) continue;
|
||||
|
||||
// decimals must be a non-negative integer. A present-but-non-integer
|
||||
// value (e.g. "decimals": "18") would make value<int>() throw
|
||||
// type_error.302; that exception becomes dispatch_failed and the Qt
|
||||
// caller gets an EMPTY list — one malformed entry dropping every token.
|
||||
// Validate and skip just this entry (a wrong decimals would misrender
|
||||
// amounts, so fail closed) instead.
|
||||
const auto decimals = entry.find("decimals");
|
||||
if (decimals == entry.end() || !decimals->is_number_unsigned()) continue;
|
||||
|
||||
json token;
|
||||
token["symbol"] = jStr(entry, "symbol");
|
||||
token["name"] = jStr(entry, "name");
|
||||
token["definitionId"] = definition_id;
|
||||
token["holding"] = holding;
|
||||
token["decimals"] = decimals->get<int>();
|
||||
out.push_back(token);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
nlohmann::json AmmModuleImpl::buildQuoteInput(const LogosMap& request,
|
||||
const Network& net,
|
||||
bool wallet_open,
|
||||
bool fresh_wallet_accounts,
|
||||
nlohmann::json* error) {
|
||||
if (net.status != "ready") {
|
||||
*error = publicError(net.status);
|
||||
return json();
|
||||
}
|
||||
const FfiResult configResult =
|
||||
call(amm_config_id, json{{"ammProgramId", net.amm_program_id}});
|
||||
if (!configResult.ok) {
|
||||
*error = publicError("backend_error");
|
||||
return json();
|
||||
}
|
||||
const json config = readPublicAccount(jStr(configResult.value, "configId"));
|
||||
|
||||
const FfiResult pairResult = call(amm_pair_ids, json{
|
||||
{"ammProgramId", net.amm_program_id},
|
||||
{"config", config},
|
||||
{"tokenAId", request.value("tokenAId", json())},
|
||||
{"tokenBId", request.value("tokenBId", json())},
|
||||
});
|
||||
if (!pairResult.ok) {
|
||||
*error = publicError("backend_error");
|
||||
return json();
|
||||
}
|
||||
const json pairManifest = pairResult.value;
|
||||
if (jStr(pairManifest, "status") != "ok") {
|
||||
*error = publicError(jStr(pairManifest, "code"));
|
||||
return json();
|
||||
}
|
||||
|
||||
const json walletAccounts = walletAccountReads(wallet_open, fresh_wallet_accounts);
|
||||
const json snapshot = {
|
||||
{"config", config},
|
||||
{"tokenA", readPublicAccount(jStr(pairManifest, "tokenAId"))},
|
||||
{"tokenB", readPublicAccount(jStr(pairManifest, "tokenBId"))},
|
||||
{"pool", readPublicAccount(jStr(pairManifest, "poolId"))},
|
||||
{"vaultA", readPublicAccount(jStr(pairManifest, "vaultAId"))},
|
||||
{"vaultB", readPublicAccount(jStr(pairManifest, "vaultBId"))},
|
||||
{"lpDefinition", readPublicAccount(jStr(pairManifest, "lpDefinitionId"))},
|
||||
{"lpLockHolding", readPublicAccount(jStr(pairManifest, "lpLockHoldingId"))},
|
||||
{"currentTick", readPublicAccount(jStr(pairManifest, "currentTickId"))},
|
||||
{"clock", readPublicAccount(jStr(pairManifest, "clockId"))},
|
||||
{"walletAvailable", wallet_open},
|
||||
{"walletAccounts", walletAccounts},
|
||||
};
|
||||
return {
|
||||
{"networkId", net.id},
|
||||
{"networkFingerprint", net.fingerprint},
|
||||
{"ammProgramId", net.amm_program_id},
|
||||
{"request", request},
|
||||
{"snapshot", snapshot},
|
||||
};
|
||||
}
|
||||
|
||||
LogosMap AmmModuleImpl::newPositionContext(const LogosMap& request,
|
||||
bool wallet_open,
|
||||
bool refresh_wallet_accounts) {
|
||||
const Network net = network();
|
||||
if (net.status != "ready")
|
||||
return contextState(net.status, net.id, net.fingerprint);
|
||||
|
||||
const json walletAccounts = walletAccountReads(wallet_open, refresh_wallet_accounts);
|
||||
|
||||
const FfiResult configResult =
|
||||
call(amm_config_id, json{{"ammProgramId", net.amm_program_id}});
|
||||
if (!configResult.ok)
|
||||
return contextState("error", net.id, net.fingerprint, "backend_error");
|
||||
const json config = readPublicAccount(jStr(configResult.value, "configId"));
|
||||
|
||||
json configured = json::array();
|
||||
for (const auto& id : net.token_ids) configured.push_back(id);
|
||||
const json recent = stringArray(request, "recentTokenIds");
|
||||
const json resolved = stringArray(request, "resolvedTokenIds");
|
||||
|
||||
const FfiResult tokenResult = call(amm_token_ids, json{
|
||||
{"ammProgramId", net.amm_program_id},
|
||||
{"config", config},
|
||||
{"walletAccounts", walletAccounts},
|
||||
{"configuredTokenIds", configured},
|
||||
{"recentTokenIds", recent},
|
||||
{"resolvedTokenIds", resolved},
|
||||
});
|
||||
const json tokenManifest = tokenResult.value;
|
||||
if (!tokenResult.ok || jStr(tokenManifest, "status") != "ok") {
|
||||
const std::string code =
|
||||
tokenResult.ok ? jStr(tokenManifest, "code") : std::string("backend_error");
|
||||
return contextState("error", net.id, net.fingerprint,
|
||||
code.empty() ? "backend_error" : code);
|
||||
}
|
||||
|
||||
json definitions = json::array();
|
||||
for (const auto& id : tokenManifest.value("tokenIds", json::array()))
|
||||
if (id.is_string()) definitions.push_back(readPublicAccount(id.get<std::string>()));
|
||||
|
||||
const FfiResult contextResult = call(amm_context, json{
|
||||
{"networkId", net.id},
|
||||
{"networkFingerprint", net.fingerprint},
|
||||
{"ammProgramId", net.amm_program_id},
|
||||
{"walletAvailable", wallet_open},
|
||||
{"config", config},
|
||||
{"walletAccounts", walletAccounts},
|
||||
{"tokenDefinitions", definitions},
|
||||
{"configuredTokenIds", configured},
|
||||
{"recentTokenIds", recent},
|
||||
{"resolvedTokenIds", resolved},
|
||||
});
|
||||
return contextResult.ok
|
||||
? contextResult.value
|
||||
: contextState("error", net.id, net.fingerprint, "backend_error");
|
||||
}
|
||||
|
||||
LogosMap AmmModuleImpl::quoteNewPosition(const LogosMap& request, bool wallet_open) {
|
||||
const Network net = network();
|
||||
json error;
|
||||
const json input = buildQuoteInput(request, net, wallet_open, /*fresh=*/false, &error);
|
||||
if (!error.is_null()) return error;
|
||||
|
||||
const FfiResult result = call(amm_quote, input);
|
||||
return result.ok ? result.value : publicError("backend_error");
|
||||
}
|
||||
|
||||
LogosMap AmmModuleImpl::submitNewPosition(const LogosMap& request,
|
||||
const std::string& quote_hash,
|
||||
bool wallet_open,
|
||||
const std::string& fresh_lp_id) {
|
||||
if (m_requestPending) return publicError("submit_in_progress");
|
||||
if (!wallet_open) return publicError("wallet_unavailable");
|
||||
m_requestPending = true;
|
||||
struct Guard {
|
||||
bool* flag;
|
||||
~Guard() { *flag = false; }
|
||||
} guard{&m_requestPending};
|
||||
|
||||
const Network net = network();
|
||||
json error;
|
||||
const json input = buildQuoteInput(request, net, wallet_open, /*fresh=*/true, &error);
|
||||
if (!error.is_null()) return error;
|
||||
|
||||
const FfiResult quoteResult = call(amm_quote, input);
|
||||
if (!quoteResult.ok) return publicError("backend_error");
|
||||
const json quote = quoteResult.value;
|
||||
if (jStr(quote, "quoteHash") != quote_hash) {
|
||||
json result = publicError("quote_changed");
|
||||
result["quote"] = quote;
|
||||
return result;
|
||||
}
|
||||
if (!quote.value("canSubmit", false)) {
|
||||
json result = publicError("quote_not_submittable");
|
||||
result["quote"] = quote;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fresh LP holding: the app owns wallet-keyset mutation. If the quote needs
|
||||
// one and the caller hasn't supplied it, ask for it (no submit) so the
|
||||
// backend can create it through its own wallet provider and call again.
|
||||
json freshLp; // null
|
||||
if (quote.value("requiresFreshLp", false)) {
|
||||
if (fresh_lp_id.empty()) {
|
||||
return json{
|
||||
{"schema", SCHEMA},
|
||||
{"status", "requires_fresh_lp"},
|
||||
{"quote", quote},
|
||||
};
|
||||
}
|
||||
freshLp = readPublicAccount(fresh_lp_id);
|
||||
}
|
||||
|
||||
json planInput = input;
|
||||
planInput["quoteHash"] = quote_hash;
|
||||
planInput["nowMs"] = nowMs();
|
||||
if (!freshLp.is_null()) planInput["freshLp"] = freshLp;
|
||||
|
||||
const FfiResult planResult = call(amm_plan, planInput);
|
||||
if (!planResult.ok) return publicError("backend_error");
|
||||
const json plan = planResult.value;
|
||||
if (jStr(plan, "status") != "ready") {
|
||||
const std::string code = jStr(plan, "code");
|
||||
return publicError(code.empty() ? "wallet_submission_failed" : code);
|
||||
}
|
||||
|
||||
uint64_t deadline = 0;
|
||||
if (!parseU64(jStr(plan, "deadlineMs"), deadline) || nowMs() >= deadline)
|
||||
return publicError("transaction_deadline_expired");
|
||||
|
||||
const std::vector<std::string> accounts = jsonStrVec(plan.value("accountIds", json::array()));
|
||||
const std::vector<bool> signers = jsonBoolVec(plan.value("signingRequirements", json::array()));
|
||||
const std::vector<uint8_t> instruction = jsonWordsToLeBytes(plan.value("instruction", json::array()));
|
||||
const std::string program_id = jStr(plan, "programId");
|
||||
|
||||
const std::string reply = modules().logos_execution_zone.send_generic_public_transaction(
|
||||
accounts, signers, instruction, program_id);
|
||||
const auto obj = json::parse(reply, nullptr, /*allow_exceptions=*/false);
|
||||
if (!obj.is_object() || !obj.value("success", false))
|
||||
return publicError("wallet_submission_failed");
|
||||
|
||||
// Native tx hash (64-char hex) -> base58 transaction id via the wallet
|
||||
// module (avoids linking libbase58 just for this encode).
|
||||
const std::string tx_hash = jStr(obj, "tx_hash");
|
||||
const std::string transaction_id =
|
||||
modules().logos_execution_zone.account_id_to_base58(tx_hash);
|
||||
if (transaction_id.empty()) return publicError("wallet_submission_failed");
|
||||
|
||||
return {
|
||||
{"schema", SCHEMA},
|
||||
{"status", "submitted"},
|
||||
{"transactionId", transaction_id},
|
||||
{"deadlineMs", plan.value("deadlineMs", json())},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <logos_json.h> // LogosMap / LogosList (nlohmann::json aliases)
|
||||
#include <logos_module_context.h> // LogosModuleContext base + modules()
|
||||
|
||||
// AMM business logic as a universal core Logos module.
|
||||
//
|
||||
// Orchestration only: the AMM domain math (PDA derivation, on-chain account
|
||||
// decoding, quote/plan computation, and instruction encoding) lives in the Rust
|
||||
// `amm_client` crate and is reached through its JSON FFI (amm_client.h — one
|
||||
// `char* op(const char*)` per operation, request and response both JSON). This
|
||||
// module sequences those ops with chain I/O delegated to the
|
||||
// `logos_execution_zone` wallet module (reached via modules().logos_execution_zone).
|
||||
//
|
||||
// The same surface is consumed by the QML UI (via modules().amm_module) and
|
||||
// headlessly (logoscore call amm_module ...). Ported from the app-side
|
||||
// SwapRuntime / NewPositionRuntime orchestration (apps/amm/src) plus the
|
||||
// backend's network-context derivation, made Qt-free (std::string / LogosMap /
|
||||
// LogosList / nlohmann::json) as the universal authoring model requires.
|
||||
//
|
||||
// Public methods ARE the module's API; the Qt plugin glue is generated from
|
||||
// this header because metadata.json sets "interface": "universal". Keep the
|
||||
// header Qt-free — std types only.
|
||||
class AmmModuleImpl : public LogosModuleContext {
|
||||
public:
|
||||
AmmModuleImpl() = default;
|
||||
~AmmModuleImpl() = default;
|
||||
|
||||
/// Derives the pool PDAs (config / pool / vaults / current-tick) for the
|
||||
/// (def_a_hex, def_b_hex) pair and reads the pool's on-chain reserves.
|
||||
/// On success: `{ exists:true, reserveA, reserveB, feeBps }` (reserveA/
|
||||
/// reserveB in the pool's canonical def order). Otherwise
|
||||
/// `{ exists:false, error:<code> }`: `no_program_bin` (AMM_PROGRAM_BIN
|
||||
/// unset/unreadable/bad), `amm_not_initialized` (config undecodable),
|
||||
/// `bad_config` (bad ids / internal decode failure), `same_token_pair`, or
|
||||
/// `no_pool` for the ordinary "no pool / no liquidity yet" state.
|
||||
LogosMap resolvePool(const std::string& def_a_hex, const std::string& def_b_hex);
|
||||
|
||||
/// Submits an on-chain SwapExactInput transaction against the pool for
|
||||
/// (def_a_hex = token in, def_b_hex = token out). amount_in / min_out are
|
||||
/// u128 base-unit amounts; deadline is a u64 unix-ms timestamp. Each accepts
|
||||
/// EITHER a small JSON integer (bare `1000` on the CLI) OR a decimal string
|
||||
/// (what the UI passes, and what the CLI must use for any big value — large
|
||||
/// amounts and the unix-ms deadline — as a quote-wrapped arg like
|
||||
/// '"1000000000000000000"'). Declared `nlohmann::json` so the generated
|
||||
/// dispatch hands us the raw value; JSON floats are rejected rather than
|
||||
/// submit a silently-rounded amount. Returns the tx hash, or an empty string
|
||||
/// on failure (no pool, unreadable AMM_PROGRAM_BIN, bad inputs, failed tx).
|
||||
std::string swapExactInput(const std::string& def_a_hex,
|
||||
const std::string& def_b_hex,
|
||||
const std::string& user_input_holding_hex,
|
||||
const std::string& user_output_holding_hex,
|
||||
const nlohmann::json& amount_in,
|
||||
const nlohmann::json& min_out,
|
||||
const nlohmann::json& deadline);
|
||||
|
||||
/// Reads the token list config at TOKENS_CONFIG (a JSON array of
|
||||
/// { symbol, name, definitionId, holding, decimals }) and returns it,
|
||||
/// normalizing definitionId/holding to lowercase hex. Empty list if
|
||||
/// TOKENS_CONFIG is unset / unreadable / not a JSON array.
|
||||
LogosList tokenList();
|
||||
|
||||
/// New-position (add-liquidity) view state: reads the AMM config + the
|
||||
/// user's wallet accounts and returns the `new-position.v1` context map the
|
||||
/// UI renders (available tokens, fee tiers, warnings). `wallet_open` gates
|
||||
/// whether wallet accounts are included; `refresh_wallet_accounts` forces a
|
||||
/// fresh read rather than a cached one.
|
||||
LogosMap newPositionContext(const LogosMap& request,
|
||||
bool wallet_open,
|
||||
bool refresh_wallet_accounts);
|
||||
|
||||
/// Prices an add-liquidity request against current on-chain state and
|
||||
/// returns the `new-position.v1` quote map (quoteHash, canSubmit,
|
||||
/// requiresFreshLp, amounts, warnings). Read-only — no submission.
|
||||
LogosMap quoteNewPosition(const LogosMap& request, bool wallet_open);
|
||||
|
||||
/// Submits an add-liquidity transaction. Re-quotes and validates `quote_hash`
|
||||
/// against the current quote. If the quote requires a fresh LP holding and
|
||||
/// `fresh_lp_id` is empty, returns `{ "status": "requires_fresh_lp", ... }`
|
||||
/// WITHOUT submitting — the caller (the app backend, which owns the wallet
|
||||
/// keyset) creates the account and calls again with its id. Otherwise builds
|
||||
/// the plan (injecting the fresh LP account when given) and submits, then
|
||||
/// returns the `new-position.v1` submitted/error map.
|
||||
LogosMap submitNewPosition(const LogosMap& request,
|
||||
const std::string& quote_hash,
|
||||
bool wallet_open,
|
||||
const std::string& fresh_lp_id);
|
||||
|
||||
private:
|
||||
// Off-chain "network" context, derived from the process env (the same
|
||||
// sources the app backend used): AMM deployment id from AMM_PROGRAM_BIN,
|
||||
// configured token set from TOKENS_CONFIG. `status` is "ready" once the
|
||||
// program id resolves, else "config_missing".
|
||||
struct Network {
|
||||
std::string id = "lez";
|
||||
std::string status;
|
||||
std::string fingerprint; // == amm_program_id (binds a quote to the deploy)
|
||||
std::string amm_program_id; // 64-char lowercase hex
|
||||
std::vector<std::string> token_ids;
|
||||
};
|
||||
// AMM_PROGRAM_BIN / TOKENS_CONFIG are fixed for the process lifetime, and
|
||||
// this runs on the hot reply path (every op), so it resolves the program id
|
||||
// + token ids ONCE and caches them (networkResolved). Cached only on
|
||||
// success, so a startup miss (bin not readable yet) retries.
|
||||
Network network();
|
||||
|
||||
// 64-char lowercase-hex AMM program id via the amm_client `program_id` op
|
||||
// over the AMM_PROGRAM_BIN bytes (empty if unset/unreadable/bad).
|
||||
std::string ammProgramId();
|
||||
|
||||
// Reads AMM_PROGRAM_BIN into a byte vector (empty on unset/unreadable/empty).
|
||||
std::vector<uint8_t> loadAmmElf();
|
||||
|
||||
// Normalizes an account id given as 64-char hex or base58 to lowercase hex
|
||||
// (base58 via the wallet module). Empty string if it is neither.
|
||||
std::string normalizeAccountId(const std::string& id);
|
||||
|
||||
// Derives the config account id (amm_config_id) and reads it, returning the
|
||||
// account-read shape the amm_client ops embed. Null json when the config_id
|
||||
// op itself fails (readPublicAccount always yields at least {id,status}).
|
||||
nlohmann::json readConfig(const Network& net);
|
||||
|
||||
// Reads a public account through the wallet module and returns the
|
||||
// { id, status, account:{ program_owner, balance, nonce, data } } shape the
|
||||
// amm_client ops expect (see the app-side accountReadJson). `account` is
|
||||
// omitted when the read has no data (uninitialized/nonexistent).
|
||||
nlohmann::json readPublicAccount(const std::string& account_id);
|
||||
|
||||
// The user's own public account reads (empty when the wallet is closed).
|
||||
// Cached across calls (walletAccounts); `refresh` reloads instead of serving
|
||||
// the cache — quote reuses it, submit forces fresh — since each read is a
|
||||
// live sequencer round-trip.
|
||||
nlohmann::json walletAccountReads(bool wallet_open, bool refresh);
|
||||
|
||||
// Builds the { networkId, networkFingerprint, ammProgramId, request,
|
||||
// snapshot } input shared by quoteNewPosition / submitNewPosition. On a
|
||||
// recoverable precondition failure, sets *error to a new-position.v1 error
|
||||
// map and returns a null json.
|
||||
nlohmann::json buildQuoteInput(const LogosMap& request,
|
||||
const Network& net,
|
||||
bool wallet_open,
|
||||
bool fresh_wallet_accounts,
|
||||
nlohmann::json* error);
|
||||
|
||||
// Guards against a re-entrant in-flight request (e.g. a double-submit) on the
|
||||
// shared module instance. Released per call, so the app's fresh-LP resubmit
|
||||
// still proceeds.
|
||||
bool m_requestPending = false;
|
||||
|
||||
// Process-lifetime network config, resolved once (see network()). Serialized
|
||||
// module dispatch means no locking is needed; there is no invalidation, as
|
||||
// runtime env reload is not supported.
|
||||
bool networkResolved = false;
|
||||
std::string programId;
|
||||
std::vector<std::string> tokenIds;
|
||||
|
||||
// Cache of the user's public account reads for the context/quote path (each
|
||||
// read is a live sequencer round-trip). Null until first read; `refresh`
|
||||
// reloads it, and it's dropped when the wallet closes. See walletAccountReads.
|
||||
nlohmann::json walletAccounts;
|
||||
};
|
||||
Reference in New Issue
Block a user