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

Introduce modules/amm — the AMM business logic as a universal core Logos module,
consumed identically by the QML UI (via modules().amm_module) and headlessly
(logoscore call amm_module ...). The module is a thin transport adapter: the
domain math lives in the Rust amm_client crate (the transport-independent JSON
FFI), and the module sequences those pure ops with chain I/O delegated to the
logos_execution_zone wallet module. It reaches the same shared wallet instance
the UI opened (Basecamp loads core modules as singletons; standalone the
LogosAPI client cache dedups the connection), so it never opens a second wallet.

The module owns the full AMM surface — not just swaps:
- resolvePool / swapExactInput / tokenList (the swap path)
- newPositionContext / quoteNewPosition / submitNewPosition (add-liquidity)

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

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

- amounts/deadline declared nlohmann::json so the generated dispatch accepts a
  JSON number (bare small ints on the CLI) or a string (exact u128 from the UI,
  or a quote-wrapped big value on the CLI); JSON floats are rejected rather than
  submit a silently-rounded amount.
- AMM_DEBUG-gated tracing for the swap path.
- Drop tests/cpp/NewPositionRuntimeTest.cpp with the class it covered
  (module-level tests to follow).
- modules/amm/README.md: architecture, headless prerequisites, logoscore recipe.
This commit is contained in:
r4bbit
2026-08-03 23:01:53 +02:00
parent 737b2f674a
commit 72b330122d
21 changed files with 1481 additions and 1319 deletions
+3 -27
View File
@@ -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
View File
@@ -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
+3 -5
View File
@@ -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")
}
-17
View File
@@ -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;
};
-91
View File
@@ -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);
}
-38
View File
@@ -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
View File
@@ -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
View File
@@ -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
-390
View File
@@ -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();
}
-42
View File
@@ -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;
};
-169
View File
@@ -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;
}
-46
View File
@@ -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;
}