From ad7d4152e799bebed44fa3e99545da753d5e6a9c Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:21:14 +0200 Subject: [PATCH 01/11] refactor(apps/amm): decouple token/pool loading from its byte source Split the AMM UI's known-tokens/known-pools loading into two concerns: - reading raw JSON bytes from a source (`readConfigFileBytes`, currently a local file at TOKENS_CONFIG / AMM_POOLS_CONFIG), and - parsing those bytes into the UI list (`parseTokensJson` / `parsePoolsJson`). The parsers are now source-agnostic, so a remote payload can feed the exact same validation and shaping. No behavior change: the local-file env-var path is preserved, including the fail-soft / skip-malformed-entry semantics. --- apps/amm/src/AmmUiBackend.cpp | 43 +++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 5ec64c86..ba55acff 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -30,19 +30,11 @@ namespace { // skipped rather than dropping the whole list. tokenA/tokenB (display // symbols) and a numeric feeBps are required; the id fields pass through // when present so the entry can later be resolved on-chain. - QVariantList readPoolsConfig() + QVariantList parsePoolsJson(const QByteArray& bytes) { QVariantList out; - const QString path = qEnvironmentVariable(POOLS_CONFIG_ENV); - if (path.isEmpty()) - return out; - - QFile file(path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) - return out; - - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + const QJsonDocument doc = QJsonDocument::fromJson(bytes); if (!doc.isArray()) return out; @@ -81,19 +73,11 @@ namespace { // token's account ids and pass through as configured (base58 or hex) — the // module methods normalize to hex at their boundary. decimals must be a // non-negative integer (a wrong value would misrender amounts). - QVariantList readTokensConfig() + QVariantList parseTokensJson(const QByteArray& bytes) { QVariantList out; - const QString path = qEnvironmentVariable(TOKENS_CONFIG_ENV); - if (path.isEmpty()) - return out; - - QFile file(path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) - return out; - - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + const QJsonDocument doc = QJsonDocument::fromJson(bytes); if (!doc.isArray()) return out; @@ -118,6 +102,25 @@ namespace { } return out; } + + // v1 registry source: a local JSON file at an env-var path. Returns empty on + // unset/unreadable — callers fail soft. Phase 2 will add a remote source + // (AMM_REGISTRY_URL) whose fetched payload feeds the same parsers above. + QByteArray readConfigFileBytes(const char* envVar) + { + const QString path = qEnvironmentVariable(envVar); + if (path.isEmpty()) + return {}; + + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + + return file.readAll(); + } + + QVariantList readTokensConfig() { return parseTokensJson(readConfigFileBytes(TOKENS_CONFIG_ENV)); } + QVariantList readPoolsConfig() { return parsePoolsJson(readConfigFileBytes(POOLS_CONFIG_ENV)); } } From 31c133a63af24252c621e760259bbce0d036bb1b Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:14:11 +0200 Subject: [PATCH 02/11] refactor(apps/amm): extract RegistryLoader + registry-refresh plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce apps/amm/src/RegistryLoader, a QObject that owns the known-tokens / known-pools snapshot and serves it to the backend's QtRO slots. The JSON parsers move here; AmmUiBackend now reads tokenList/poolList/resolveTokens from the loader's snapshot instead of re-parsing the config files inline. Add the refresh plumbing the UI will drive: - PROP(int registryRevision READONLY) — bumped on every snapshot refresh so QML replicas re-fetch the lists. - SLOT(void refreshRegistry()) — manual re-load. No behavior change: the source is still the local TOKENS_CONFIG / AMM_POOLS_CONFIG files. This is Phase 2a of docs/amm-registry-plan.md; Phase 2b reshapes RegistryLoader::refresh() into the async remote (AMM_REGISTRY_URL) fetch — manifest + deployment guard + disk cache — with the same snapshot contract. --- apps/amm/CMakeLists.txt | 2 + apps/amm/src/AmmUiBackend.cpp | 135 ++++++-------------------------- apps/amm/src/AmmUiBackend.h | 5 ++ apps/amm/src/AmmUiBackend.rep | 9 +++ apps/amm/src/RegistryLoader.cpp | 129 ++++++++++++++++++++++++++++++ apps/amm/src/RegistryLoader.h | 45 +++++++++++ 6 files changed, 213 insertions(+), 112 deletions(-) create mode 100644 apps/amm/src/RegistryLoader.cpp create mode 100644 apps/amm/src/RegistryLoader.h diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 5ad917a8..71320deb 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -36,6 +36,8 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp + src/RegistryLoader.h + src/RegistryLoader.cpp FIND_PACKAGES Qt6Gui LINK_LIBRARIES diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index ba55acff..17cb16a5 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -14,126 +14,29 @@ #include #include "LogosWalletProvider.h" +#include "RegistryLoader.h" #include "WalletController.h" #include "logos_api.h" #include "logos_sdk.h" -namespace { - // Absolute path to the JSON known-pools config consumed by poolList(). - // Mirrors TOKENS_CONFIG for the token list; produced by the AMM testnet - // setup script (apps/amm/tests/testnet/setup-amm-testnet.sh). - constexpr char POOLS_CONFIG_ENV[] = "AMM_POOLS_CONFIG"; - - // Parses the AMM_POOLS_CONFIG JSON file into the QVariantList the Pools UI - // renders. Fails soft (empty list) when the env var is unset, the file is - // unreadable, or the payload is not a JSON array — one malformed entry is - // skipped rather than dropping the whole list. tokenA/tokenB (display - // symbols) and a numeric feeBps are required; the id fields pass through - // when present so the entry can later be resolved on-chain. - QVariantList parsePoolsJson(const QByteArray& bytes) - { - QVariantList out; - - const QJsonDocument doc = QJsonDocument::fromJson(bytes); - if (!doc.isArray()) - return out; - - for (const QJsonValue& entry : doc.array()) { - if (!entry.isObject()) - continue; - const QJsonObject obj = entry.toObject(); - - const QString tokenA = obj.value(QStringLiteral("tokenA")).toString(); - const QString tokenB = obj.value(QStringLiteral("tokenB")).toString(); - const QJsonValue feeBps = obj.value(QStringLiteral("feeBps")); - if (tokenA.isEmpty() || tokenB.isEmpty() || !feeBps.isDouble()) - continue; - - QVariantMap pool; - pool.insert(QStringLiteral("tokenA"), tokenA); - pool.insert(QStringLiteral("tokenB"), tokenB); - pool.insert(QStringLiteral("feeBps"), feeBps.toInt()); - pool.insert(QStringLiteral("poolId"), - obj.value(QStringLiteral("poolId")).toString()); - pool.insert(QStringLiteral("tokenADefinitionId"), - obj.value(QStringLiteral("tokenADefinitionId")).toString()); - pool.insert(QStringLiteral("tokenBDefinitionId"), - obj.value(QStringLiteral("tokenBDefinitionId")).toString()); - out.append(pool); - } - return out; - } - - // Absolute path to the JSON token-list config consumed by tokenList(). - constexpr char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; - - // Parses the TOKENS_CONFIG JSON file into the QVariantList the Swap token - // picker renders. Same fail-soft, skip-malformed-entry behavior as - // readPoolsConfig(). symbol/name are display; definitionId/holding are the - // token's account ids and pass through as configured (base58 or hex) — the - // module methods normalize to hex at their boundary. decimals must be a - // non-negative integer (a wrong value would misrender amounts). - QVariantList parseTokensJson(const QByteArray& bytes) - { - QVariantList out; - - const QJsonDocument doc = QJsonDocument::fromJson(bytes); - if (!doc.isArray()) - return out; - - for (const QJsonValue& entry : doc.array()) { - if (!entry.isObject()) - continue; - const QJsonObject obj = entry.toObject(); - - const QString definitionId = obj.value(QStringLiteral("definitionId")).toString(); - const QString holding = obj.value(QStringLiteral("holding")).toString(); - const QJsonValue decimals = obj.value(QStringLiteral("decimals")); - if (definitionId.isEmpty() || holding.isEmpty() || !decimals.isDouble()) - continue; - - QVariantMap token; - token.insert(QStringLiteral("symbol"), obj.value(QStringLiteral("symbol")).toString()); - token.insert(QStringLiteral("name"), obj.value(QStringLiteral("name")).toString()); - token.insert(QStringLiteral("definitionId"), definitionId); - token.insert(QStringLiteral("holding"), holding); - token.insert(QStringLiteral("decimals"), decimals.toInt()); - out.append(token); - } - return out; - } - - // v1 registry source: a local JSON file at an env-var path. Returns empty on - // unset/unreadable — callers fail soft. Phase 2 will add a remote source - // (AMM_REGISTRY_URL) whose fetched payload feeds the same parsers above. - QByteArray readConfigFileBytes(const char* envVar) - { - const QString path = qEnvironmentVariable(envVar); - if (path.isEmpty()) - return {}; - - QFile file(path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) - return {}; - - return file.readAll(); - } - - QVariantList readTokensConfig() { return parseTokensJson(readConfigFileBytes(TOKENS_CONFIG_ENV)); } - QVariantList readPoolsConfig() { return parsePoolsJson(readConfigFileBytes(POOLS_CONFIG_ENV)); } -} - - AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), m_logos(std::make_unique(m_logosAPI)), m_wallet(std::make_unique(m_logosAPI)), m_walletController(std::make_unique( - *m_wallet, QStringLiteral("AmmUI"))) + *m_wallet, QStringLiteral("AmmUI"))), + m_registry(std::make_unique()) { setWalletStateReady(false); + // Load the known-tokens / known-pools registry, and bump registryRevision on + // every refresh so QML replicas re-fetch tokenList()/poolList()/resolveTokens(). + connect(m_registry.get(), &RegistryLoader::changed, this, [this]() { + setRegistryRevision(m_registry->revision()); + }); + m_registry->refresh(); + connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); // Publishes an initial "loading" context (walletStateReady is still false, @@ -333,8 +236,16 @@ QVariantList AmmUiBackend::tokenList() // Config-driven token list, read straight from TOKENS_CONFIG (like poolList // reads AMM_POOLS_CONFIG). Token discovery is an app concern, so this stays // in the backend rather than the amm_module; the swap/quote module methods - // normalize the ids (base58 or hex) at their boundary. - return readTokensConfig(); + // normalize the ids (base58 or hex) at their boundary. Served from the + // RegistryLoader snapshot (re-fetched when registryRevision changes). + return m_registry->tokens(); +} + +void AmmUiBackend::refreshRegistry() +{ + // Manual re-load of the known-tokens/known-pools source. The loader bumps + // registryRevision and the UI re-fetches the lists. + m_registry->refresh(); } QVariantMap AmmUiBackend::createPoolQuote(QVariantMap request) @@ -370,8 +281,8 @@ QVariantList AmmUiBackend::poolList() // Config-driven known pools. Read straight from AMM_POOLS_CONFIG on every // call (the UI fetches this once on load); adding more pairs is a config // edit, no app change. Pool discovery is an app concern, so this stays in - // the backend rather than the amm_module. - return readPoolsConfig(); + // the backend rather than the amm_module. Served from the RegistryLoader snapshot. + return m_registry->pools(); } QVariantList AmmUiBackend::feeTiers() @@ -391,7 +302,7 @@ QVariantList AmmUiBackend::resolveTokens() const bool wallet_open = isWalletOpen(); QVariantList ids; - const QVariantList configured = readTokensConfig(); + const QVariantList configured = m_registry->tokens(); for (const QVariant& entry : configured) { const QString id = entry.toMap().value(QStringLiteral("definitionId")).toString(); if (!id.isEmpty()) diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 80c1eabf..1fb73621 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -18,6 +18,7 @@ class LogosAPI; struct LogosModules; class LogosWalletProvider; class WalletController; +class RegistryLoader; // Source-side implementation of the AmmUiBackend .rep interface. // Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and @@ -101,6 +102,8 @@ public slots: QVariantList resolveTokens() override; // Validates + persists a user-pasted custom token id (see the .rep). QVariantMap addCustomToken(QString tokenId) override; + // Re-loads the known-tokens / known-pools registry (bumps registryRevision). + void refreshRegistry() override; private: void syncWalletState(); @@ -120,6 +123,8 @@ public slots: std::unique_ptr m_logos; std::unique_ptr m_wallet; std::unique_ptr m_walletController; + // Known-tokens / known-pools snapshot source (local files now; remote later). + std::unique_ptr m_registry; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index f5cf80fc..e566432e 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -18,6 +18,10 @@ class AmmUiBackend // Whether the configured sequencer answered the last reachability probe. // Defaults true so the UI doesn't flash a warning before the first check. PROP(bool sequencerReachable READONLY) + // Bumps on every known-tokens / known-pools registry refresh so QML replicas + // re-fetch tokenList()/poolList()/resolveTokens(). Starts at 0; the backend + // sets it once the initial snapshot has loaded (and again on each refresh). + PROP(int registryRevision READONLY) // Account management SLOT(QString createAccountPublic()) @@ -201,4 +205,9 @@ class AmmUiBackend // and returns { ok: true, token: } with the resolved row; on an unresolvable / // non-fungible id returns { ok: false, error: "unresolved" } and persists nothing. SLOT(QVariantMap addCustomToken(QString tokenId)) + + // Re-loads the known-tokens / known-pools source (the local files, or later + // a remote registry). Bumps registryRevision when the snapshot updates so + // QML re-fetches the lists. + SLOT(void refreshRegistry()) } diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp new file mode 100644 index 00000000..3cecd46e --- /dev/null +++ b/apps/amm/src/RegistryLoader.cpp @@ -0,0 +1,129 @@ +#include "RegistryLoader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + // Absolute path to the JSON known-pools config consumed by the pools list. + // Mirrors TOKENS_CONFIG for the token list; produced by the AMM testnet + // setup script (apps/amm/tests/testnet/setup-amm-testnet.sh). + constexpr char POOLS_CONFIG_ENV[] = "AMM_POOLS_CONFIG"; + + // Parses the pools JSON payload into the QVariantList the Pools UI renders. + // Source-agnostic (local file or remote payload). Fails soft (empty list) + // when the payload is not a JSON array — one malformed entry is skipped + // rather than dropping the whole list. tokenA/tokenB (display symbols) and a + // numeric feeBps are required; the id fields pass through when present so the + // entry can later be resolved on-chain. + QVariantList parsePoolsJson(const QByteArray& bytes) + { + QVariantList out; + + const QJsonDocument doc = QJsonDocument::fromJson(bytes); + if (!doc.isArray()) + return out; + + for (const QJsonValue& entry : doc.array()) { + if (!entry.isObject()) + continue; + const QJsonObject obj = entry.toObject(); + + const QString tokenA = obj.value(QStringLiteral("tokenA")).toString(); + const QString tokenB = obj.value(QStringLiteral("tokenB")).toString(); + const QJsonValue feeBps = obj.value(QStringLiteral("feeBps")); + if (tokenA.isEmpty() || tokenB.isEmpty() || !feeBps.isDouble()) + continue; + + QVariantMap pool; + pool.insert(QStringLiteral("tokenA"), tokenA); + pool.insert(QStringLiteral("tokenB"), tokenB); + pool.insert(QStringLiteral("feeBps"), feeBps.toInt()); + pool.insert(QStringLiteral("poolId"), + obj.value(QStringLiteral("poolId")).toString()); + pool.insert(QStringLiteral("tokenADefinitionId"), + obj.value(QStringLiteral("tokenADefinitionId")).toString()); + pool.insert(QStringLiteral("tokenBDefinitionId"), + obj.value(QStringLiteral("tokenBDefinitionId")).toString()); + out.append(pool); + } + return out; + } + + // Absolute path to the JSON token-list config consumed by the token list. + constexpr char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; + + // Parses the tokens JSON payload into the QVariantList the Swap token picker + // renders. Same fail-soft, skip-malformed-entry behavior as parsePoolsJson(). + // symbol/name are display; definitionId/holding are the token's account ids + // and pass through as configured (base58 or hex) — the module methods + // normalize to hex at their boundary. decimals must be a non-negative integer + // (a wrong value would misrender amounts). + QVariantList parseTokensJson(const QByteArray& bytes) + { + QVariantList out; + + const QJsonDocument doc = QJsonDocument::fromJson(bytes); + if (!doc.isArray()) + return out; + + for (const QJsonValue& entry : doc.array()) { + if (!entry.isObject()) + continue; + const QJsonObject obj = entry.toObject(); + + const QString definitionId = obj.value(QStringLiteral("definitionId")).toString(); + const QString holding = obj.value(QStringLiteral("holding")).toString(); + const QJsonValue decimals = obj.value(QStringLiteral("decimals")); + if (definitionId.isEmpty() || holding.isEmpty() || !decimals.isDouble()) + continue; + + QVariantMap token; + token.insert(QStringLiteral("symbol"), obj.value(QStringLiteral("symbol")).toString()); + token.insert(QStringLiteral("name"), obj.value(QStringLiteral("name")).toString()); + token.insert(QStringLiteral("definitionId"), definitionId); + token.insert(QStringLiteral("holding"), holding); + token.insert(QStringLiteral("decimals"), decimals.toInt()); + out.append(token); + } + return out; + } + + // v1 registry source: a local JSON file at an env-var path. Returns empty on + // unset/unreadable — callers fail soft. A later phase adds a remote source + // (AMM_REGISTRY_URL) whose fetched payload feeds the same parsers above. + QByteArray readConfigFileBytes(const char* envVar) + { + const QString path = qEnvironmentVariable(envVar); + if (path.isEmpty()) + return {}; + + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + + return file.readAll(); + } +} + +RegistryLoader::RegistryLoader(QObject* parent) + : QObject(parent) +{ +} + +void RegistryLoader::refresh() +{ + // v1 source: the local JSON files. The parsers are source-agnostic, so a + // later remote source (AMM_REGISTRY_URL) can feed the same validation and + // shaping without touching the consumers. + m_tokens = parseTokensJson(readConfigFileBytes(TOKENS_CONFIG_ENV)); + m_pools = parsePoolsJson(readConfigFileBytes(POOLS_CONFIG_ENV)); + ++m_revision; + emit changed(); +} diff --git a/apps/amm/src/RegistryLoader.h b/apps/amm/src/RegistryLoader.h new file mode 100644 index 00000000..0f4db315 --- /dev/null +++ b/apps/amm/src/RegistryLoader.h @@ -0,0 +1,45 @@ +#ifndef AMM_UI_REGISTRY_LOADER_H +#define AMM_UI_REGISTRY_LOADER_H + +#include +#include + +// Loads the AMM app's "known tokens" and "known pools" and serves them as an +// in-memory snapshot that the backend's QtRO slots read synchronously. +// +// The source is resolved on refresh(): the local JSON files at TOKENS_CONFIG / +// AMM_POOLS_CONFIG (dev / local-sequencer testing). refresh() re-reads the +// source, bumps revision(), and emits changed() when the snapshot updates, so +// the backend can re-publish its registryRevision PROP and the UI can re-fetch. +// +// A later phase adds a remote GitHub registry (AMM_REGISTRY_URL) that refreshes +// asynchronously; this snapshot/consumer contract stays the same — only the +// body of refresh() changes. +class RegistryLoader : public QObject { + Q_OBJECT + +public: + explicit RegistryLoader(QObject* parent = nullptr); + + // Current snapshot. Safe to call from the backend's QtRO slots. + QVariantList tokens() const { return m_tokens; } + QVariantList pools() const { return m_pools; } + // Increments on every snapshot update — the value published as + // AmmUiBackend::registryRevision so QML replicas re-fetch the lists. + int revision() const { return m_revision; } + +public slots: + // (Re)load the configured source into the snapshot. Synchronous for the + // local-file source; emits changed() when the snapshot has been refreshed. + void refresh(); + +signals: + void changed(); + +private: + QVariantList m_tokens; + QVariantList m_pools; + int m_revision = 0; +}; + +#endif // AMM_UI_REGISTRY_LOADER_H From ae4f01fdec23b7c3ed5732ec7d3ef57676597aad Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:52:59 +0200 Subject: [PATCH 03/11] feat(apps/amm): load known tokens/pools from a remote registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no local TOKENS_CONFIG / AMM_POOLS_CONFIG is set, RegistryLoader now loads the known-tokens / known-pools from a remote GitHub registry named by AMM_REGISTRY_URL: an async QNetworkAccessManager fetch of a registry.json manifest, then the tokens.json / pools.json it points at (resolved relative to the manifest URL). - Stale-while-revalidate: the on-disk cache is served immediately and revalidated against the network (the manifest `timestamp` is the freshness key); a failed/offline fetch keeps the last cache. - Deployment guard: a manifest whose programIds don't match the app's deployment (from configAccount) is rejected; a manifest that omits programIds is trusted. - Precedence unchanged: local files replace the remote source when set; user custom tokens still merge on top. - registryRevision bumps on each snapshot update; SwapPage/PoolsPage/ LiquidityPage re-fetch on it. CMake links Qt6::Network. Add apps/amm/registry-sample/ — a manifest plus empty tokens.json / pools.json and a README — to exercise the remote path end to end (start with the empty case). Compile-verified via `nix build .#amm-ui`; not yet run end to end. --- apps/amm/CMakeLists.txt | 1 + apps/amm/qml/pages/LiquidityPage.qml | 2 + apps/amm/qml/pages/PoolsPage.qml | 6 + apps/amm/qml/pages/SwapPage.qml | 14 +- apps/amm/registry-sample/README.md | 57 +++++++ apps/amm/registry-sample/pools.json | 1 + apps/amm/registry-sample/registry.json | 12 ++ apps/amm/registry-sample/tokens.json | 1 + apps/amm/src/AmmUiBackend.cpp | 18 +- apps/amm/src/RegistryLoader.cpp | 223 +++++++++++++++++++++++-- apps/amm/src/RegistryLoader.h | 71 ++++++-- 11 files changed, 372 insertions(+), 34 deletions(-) create mode 100644 apps/amm/registry-sample/README.md create mode 100644 apps/amm/registry-sample/pools.json create mode 100644 apps/amm/registry-sample/registry.json create mode 100644 apps/amm/registry-sample/tokens.json diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 71320deb..57a2b394 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -42,6 +42,7 @@ logos_module( Qt6Gui LINK_LIBRARIES Qt6::Gui + Qt6::Network LINK_TARGETS logos_wallet_access ) diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 7e606aa6..454c5201 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -156,6 +156,8 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers(); root.refresh Connections { target: root.backend function onIsWalletOpenChanged() { root.refreshHoldings(); root.refreshTokens() } + // Re-fetch when the registry snapshot refreshes (e.g. a remote list lands). + function onRegistryRevisionChanged() { root.refreshTokens() } } readonly property int pageMargin: width < 640 ? 16 : 24 diff --git a/apps/amm/qml/pages/PoolsPage.qml b/apps/amm/qml/pages/PoolsPage.qml index 6962a189..ca469332 100644 --- a/apps/amm/qml/pages/PoolsPage.qml +++ b/apps/amm/qml/pages/PoolsPage.qml @@ -41,6 +41,12 @@ Item { onBackendChanged: root.loadPools() onRuntimeChanged: root.loadPools() + Connections { + target: root.backend + // Re-fetch when the registry snapshot refreshes (e.g. a remote list lands). + function onRegistryRevisionChanged() { root.loadPools() } + } + AmmTheme { id: theme } diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index c6e626eb..603acd81 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -98,11 +98,17 @@ Item { }) } + function loadTokens() { + if (!root.backend) + return + logos.watch(root.backend.tokenList(), + function(list) { root.tokens = list }, + function(err) { console.warn("tokenList error:", err) }) + } + onBackendChanged: { if (root.backend) { - logos.watch(root.backend.tokenList(), - function(list) { root.tokens = list }, - function(err) { console.warn("tokenList error:", err) }) + root.loadTokens() root.refreshHoldings() } } @@ -110,6 +116,8 @@ Item { Connections { target: root.backend function onIsWalletOpenChanged() { root.refreshHoldings() } + // Re-fetch when the registry snapshot refreshes (e.g. a remote list lands). + function onRegistryRevisionChanged() { root.loadTokens() } } QtObject { diff --git a/apps/amm/registry-sample/README.md b/apps/amm/registry-sample/README.md new file mode 100644 index 00000000..d743dbe9 --- /dev/null +++ b/apps/amm/registry-sample/README.md @@ -0,0 +1,57 @@ +# AMM registry — sample + +A minimal remote **known-tokens / known-pools registry** for the AMM app, so you +can test the remote-loading path (`AMM_REGISTRY_URL`) end to end. See +`docs/amm-registry-plan.md` for the full design. + +This sample deliberately ships **empty** `tokens.json` / `pools.json` — the first +thing to verify is that the app loads and behaves sanely when the registry +resolves successfully but is empty. + +## Files + +| File | Role | +|---|---| +| `registry.json` | The **manifest** `AMM_REGISTRY_URL` points at. Names the token/pool files and the deployment the list targets. | +| `tokens.json` | The known-tokens array (currently `[]`). | +| `pools.json` | The known-pools array (currently `[]`). | + +`tokensUrl` / `poolsUrl` in the manifest are resolved **relative to the manifest +URL**, so all three files just need to sit in the same directory. + +## How to test + +1. Push this directory on a branch of your fork/repo (e.g. `logos-blockchain/lez-programs`). +2. Point the app at the manifest's **raw** URL, and make sure the local-file + overrides are unset (a local `TOKENS_CONFIG` / `AMM_POOLS_CONFIG` replaces the + remote source entirely): + + ```bash + unset TOKENS_CONFIG AMM_POOLS_CONFIG + AMM_REGISTRY_URL=https://raw.githubusercontent.com//lez-programs//apps/amm/registry-sample/registry.json \ + nix run .#amm-ui + ``` + +The app fetches `registry.json`, then `tokens.json` + `pools.json`, caches them +under the app's data dir, and shows the (empty) lists. A failed/unreachable fetch +falls back to the last cached copy. + +## Manifest fields + +- `name`, `version`, `timestamp`, `network` — informational. `timestamp` is the + freshness key: **bump it whenever you edit `tokens.json` / `pools.json`** so the + app re-downloads them instead of serving its cache. +- `tokensUrl`, `poolsUrl` — required; relative to the manifest URL. +- `programIds: { amm, token }` — the deployment this list targets. Left **empty** + here so the sample loads against any deployment. Fill them in (base58, from + `spel inspect` / the app's `configAccount`) to enable the **deployment guard**: + the app then rejects the list unless these match the AMM/token programs it's + connected to — which prevents showing stale IDs after a redeploy. + +## Adding tokens / pools later + +`tokens.json` entries: `{ symbol, name, definitionId, decimals }` (base58 ids; +`holding` is per-wallet and resolved by the app, so it is **not** in the shared +list). `pools.json` entries: `{ tokenA, tokenB, feeBps, poolId, +tokenADefinitionId, tokenBDefinitionId }`. Malformed entries are skipped, not +fatal. Remember to bump `timestamp`. diff --git a/apps/amm/registry-sample/pools.json b/apps/amm/registry-sample/pools.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/apps/amm/registry-sample/pools.json @@ -0,0 +1 @@ +[] diff --git a/apps/amm/registry-sample/registry.json b/apps/amm/registry-sample/registry.json new file mode 100644 index 00000000..4e50720c --- /dev/null +++ b/apps/amm/registry-sample/registry.json @@ -0,0 +1,12 @@ +{ + "name": "AMM registry (sample)", + "version": "0.1.0", + "timestamp": "2026-01-01T00:00:00Z", + "network": "testnet", + "programIds": { + "amm": "", + "token": "" + }, + "tokensUrl": "tokens.json", + "poolsUrl": "pools.json" +} diff --git a/apps/amm/registry-sample/tokens.json b/apps/amm/registry-sample/tokens.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/apps/amm/registry-sample/tokens.json @@ -0,0 +1 @@ +[] diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 17cb16a5..d1ba00e2 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -30,12 +30,11 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) { setWalletStateReady(false); - // Load the known-tokens / known-pools registry, and bump registryRevision on - // every refresh so QML replicas re-fetch tokenList()/poolList()/resolveTokens(). + // Bump registryRevision whenever the known-tokens / known-pools snapshot + // refreshes so QML replicas re-fetch tokenList()/poolList()/resolveTokens(). connect(m_registry.get(), &RegistryLoader::changed, this, [this]() { setRegistryRevision(m_registry->revision()); }); - m_registry->refresh(); connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); @@ -46,6 +45,19 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) QTimer::singleShot(0, this, [this]() { setWalletStateReady(true); syncWalletState(); + // Load the registry once the event loop is running (the remote source + // fetches asynchronously). Only the remote source needs the deployment + // guard, so skip the sequencer-touching configAccount read when local + // files are configured (they take precedence anyway). + if (!RegistryLoader::hasLocalSource()) { + const QVariantMap cfg = m_logos->amm_module.configAccount(); + if (cfg.value(QStringLiteral("status")).toString() == QStringLiteral("ok")) { + m_registry->setExpectedProgramIds( + cfg.value(QStringLiteral("ammProgramId")).toString(), + cfg.value(QStringLiteral("tokenProgramId")).toString()); + } + } + m_registry->refresh(); }); } diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp index 3cecd46e..de617612 100644 --- a/apps/amm/src/RegistryLoader.cpp +++ b/apps/amm/src/RegistryLoader.cpp @@ -1,20 +1,29 @@ #include "RegistryLoader.h" #include +#include +#include #include +#include #include #include #include #include #include +#include +#include +#include +#include #include #include namespace { - // Absolute path to the JSON known-pools config consumed by the pools list. - // Mirrors TOKENS_CONFIG for the token list; produced by the AMM testnet - // setup script (apps/amm/tests/testnet/setup-amm-testnet.sh). + // Local-file source (dev / local-sequencer). Takes precedence over the + // remote registry when either is set. + constexpr char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; constexpr char POOLS_CONFIG_ENV[] = "AMM_POOLS_CONFIG"; + // Remote source: the URL of a registry manifest (registry.json). + constexpr char REGISTRY_URL_ENV[] = "AMM_REGISTRY_URL"; // Parses the pools JSON payload into the QVariantList the Pools UI renders. // Source-agnostic (local file or remote payload). Fails soft (empty list) @@ -56,9 +65,6 @@ namespace { return out; } - // Absolute path to the JSON token-list config consumed by the token list. - constexpr char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; - // Parses the tokens JSON payload into the QVariantList the Swap token picker // renders. Same fail-soft, skip-malformed-entry behavior as parsePoolsJson(). // symbol/name are display; definitionId/holding are the token's account ids @@ -81,7 +87,9 @@ namespace { const QString definitionId = obj.value(QStringLiteral("definitionId")).toString(); const QString holding = obj.value(QStringLiteral("holding")).toString(); const QJsonValue decimals = obj.value(QStringLiteral("decimals")); - if (definitionId.isEmpty() || holding.isEmpty() || !decimals.isDouble()) + // holding is per-wallet and absent from a shared remote list; only + // definitionId + a valid decimals are required for a token to render. + if (definitionId.isEmpty() || !decimals.isDouble()) continue; QVariantMap token; @@ -95,9 +103,8 @@ namespace { return out; } - // v1 registry source: a local JSON file at an env-var path. Returns empty on - // unset/unreadable — callers fail soft. A later phase adds a remote source - // (AMM_REGISTRY_URL) whose fetched payload feeds the same parsers above. + // Reads a local JSON file at an env-var path. Returns empty on + // unset/unreadable — callers fail soft. QByteArray readConfigFileBytes(const char* envVar) { const QString path = qEnvironmentVariable(envVar); @@ -117,13 +124,199 @@ RegistryLoader::RegistryLoader(QObject* parent) { } +bool RegistryLoader::hasLocalSource() +{ + return !qEnvironmentVariableIsEmpty(TOKENS_CONFIG_ENV) + || !qEnvironmentVariableIsEmpty(POOLS_CONFIG_ENV); +} + +void RegistryLoader::setExpectedProgramIds(const QString& ammProgramId, + const QString& tokenProgramId) +{ + m_expectedAmmProgramId = ammProgramId; + m_expectedTokenProgramId = tokenProgramId; +} + void RegistryLoader::refresh() { - // v1 source: the local JSON files. The parsers are source-agnostic, so a - // later remote source (AMM_REGISTRY_URL) can feed the same validation and - // shaping without touching the consumers. - m_tokens = parseTokensJson(readConfigFileBytes(TOKENS_CONFIG_ENV)); - m_pools = parsePoolsJson(readConfigFileBytes(POOLS_CONFIG_ENV)); + // Supersede any in-flight remote fetch (a reply from an older generation is + // dropped in its finished handler). + ++m_generation; + + // local-replaces-remote: a configured local file wins outright. + if (hasLocalSource()) { + loadLocal(); + return; + } + + const QString url = qEnvironmentVariable(REGISTRY_URL_ENV); + if (url.isEmpty()) { + publish({}, {}, QStringLiteral("none")); + return; + } + + // stale-while-revalidate: serve the on-disk cache immediately when we have + // nothing yet, then revalidate against the network below. + if (m_tokens.isEmpty() && m_pools.isEmpty()) + loadDiskCache(url); + + startRemote(QUrl(url)); +} + +void RegistryLoader::loadLocal() +{ + publish(parseTokensJson(readConfigFileBytes(TOKENS_CONFIG_ENV)), + parsePoolsJson(readConfigFileBytes(POOLS_CONFIG_ENV)), + QStringLiteral("local")); +} + +void RegistryLoader::startRemote(const QUrl& manifestUrl) +{ + const quint64 generation = m_generation; + QNetworkReply* reply = nam()->get(QNetworkRequest(manifestUrl)); + connect(reply, &QNetworkReply::finished, this, [this, reply, manifestUrl, generation]() { + reply->deleteLater(); + if (generation != m_generation) + return; // superseded by a newer refresh + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "AMM registry: manifest fetch failed:" << reply->errorString(); + return; // keep serving whatever we have (cache / previous) + } + + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + if (!doc.isObject()) { + qWarning() << "AMM registry: manifest is not a JSON object"; + return; + } + const QJsonObject manifest = doc.object(); + if (!deploymentMatches(manifest)) { + qWarning() << "AMM registry: manifest targets a different deployment; ignoring"; + return; + } + + const QString stamp = manifest.value(QStringLiteral("timestamp")).toVariant().toString(); + // Revalidation: an unchanged manifest with a non-empty snapshot means the + // cached lists are already current — skip re-downloading them. + if (!stamp.isEmpty() && stamp == m_stamp && !m_tokens.isEmpty()) + return; + + const QString tokensRel = manifest.value(QStringLiteral("tokensUrl")).toString(); + const QString poolsRel = manifest.value(QStringLiteral("poolsUrl")).toString(); + if (tokensRel.isEmpty() || poolsRel.isEmpty()) { + qWarning() << "AMM registry: manifest missing tokensUrl/poolsUrl"; + return; + } + fetchLists(manifestUrl.resolved(QUrl(tokensRel)), + manifestUrl.resolved(QUrl(poolsRel)), stamp, generation); + }); +} + +void RegistryLoader::fetchLists(const QUrl& tokensUrl, const QUrl& poolsUrl, + const QString& stamp, quint64 generation) +{ + // Fetch the two lists in sequence, then publish both together so the UI + // never sees tokens without their pools (or vice versa). + QNetworkReply* tokensReply = nam()->get(QNetworkRequest(tokensUrl)); + connect(tokensReply, &QNetworkReply::finished, this, + [this, tokensReply, poolsUrl, stamp, generation]() { + tokensReply->deleteLater(); + if (generation != m_generation) + return; + if (tokensReply->error() != QNetworkReply::NoError) { + qWarning() << "AMM registry: tokens fetch failed:" << tokensReply->errorString(); + return; + } + const QVariantList tokens = parseTokensJson(tokensReply->readAll()); + + QNetworkReply* poolsReply = nam()->get(QNetworkRequest(poolsUrl)); + connect(poolsReply, &QNetworkReply::finished, this, + [this, poolsReply, tokens, stamp, generation]() { + poolsReply->deleteLater(); + if (generation != m_generation) + return; + if (poolsReply->error() != QNetworkReply::NoError) { + qWarning() << "AMM registry: pools fetch failed:" << poolsReply->errorString(); + return; + } + const QVariantList pools = parsePoolsJson(poolsReply->readAll()); + m_stamp = stamp; + publish(tokens, pools, QStringLiteral("remote")); + saveDiskCache(qEnvironmentVariable(REGISTRY_URL_ENV), stamp); + }); + }); +} + +bool RegistryLoader::deploymentMatches(const QJsonObject& manifest) const +{ + // No expected ids ⇒ nothing to check against (permissive). + if (m_expectedAmmProgramId.isEmpty() && m_expectedTokenProgramId.isEmpty()) + return true; + + const QJsonObject ids = manifest.value(QStringLiteral("programIds")).toObject(); + const QString amm = ids.value(QStringLiteral("amm")).toString(); + const QString token = ids.value(QStringLiteral("token")).toString(); + // A manifest that doesn't declare a deployment is trusted (the operator + // chose the URL); the guard only rejects a declared, mismatched deployment. + if (amm.isEmpty() && token.isEmpty()) + return true; + return amm == m_expectedAmmProgramId && token == m_expectedTokenProgramId; +} + +void RegistryLoader::publish(const QVariantList& tokens, const QVariantList& pools, + const QString& source) +{ + m_tokens = tokens; + m_pools = pools; + m_source = source; ++m_revision; emit changed(); } + +void RegistryLoader::loadDiskCache(const QString& url) +{ + QFile file(cachePath()); + if (!file.open(QIODevice::ReadOnly)) + return; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isObject()) + return; + const QJsonObject obj = doc.object(); + // Only trust a cache written for this same source URL. + if (obj.value(QStringLiteral("url")).toString() != url) + return; + + m_stamp = obj.value(QStringLiteral("stamp")).toVariant().toString(); + publish(obj.value(QStringLiteral("tokens")).toArray().toVariantList(), + obj.value(QStringLiteral("pools")).toArray().toVariantList(), + QStringLiteral("cache")); +} + +void RegistryLoader::saveDiskCache(const QString& url, const QString& stamp) const +{ + const QString path = cachePath(); + QDir().mkpath(QFileInfo(path).absolutePath()); + + QJsonObject obj; + obj.insert(QStringLiteral("url"), url); + obj.insert(QStringLiteral("stamp"), stamp); + obj.insert(QStringLiteral("tokens"), QJsonArray::fromVariantList(m_tokens)); + obj.insert(QStringLiteral("pools"), QJsonArray::fromVariantList(m_pools)); + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return; + file.write(QJsonDocument(obj).toJson(QJsonDocument::Compact)); +} + +QString RegistryLoader::cachePath() +{ + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/amm-registry-cache.json"); +} + +QNetworkAccessManager* RegistryLoader::nam() +{ + if (!m_nam) + m_nam = new QNetworkAccessManager(this); + return m_nam; +} diff --git a/apps/amm/src/RegistryLoader.h b/apps/amm/src/RegistryLoader.h index 0f4db315..8c6af22c 100644 --- a/apps/amm/src/RegistryLoader.h +++ b/apps/amm/src/RegistryLoader.h @@ -2,44 +2,89 @@ #define AMM_UI_REGISTRY_LOADER_H #include +#include +#include #include +class QNetworkAccessManager; +class QNetworkReply; + // Loads the AMM app's "known tokens" and "known pools" and serves them as an -// in-memory snapshot that the backend's QtRO slots read synchronously. +// in-memory snapshot the backend's QtRO slots read synchronously. // -// The source is resolved on refresh(): the local JSON files at TOKENS_CONFIG / -// AMM_POOLS_CONFIG (dev / local-sequencer testing). refresh() re-reads the -// source, bumps revision(), and emits changed() when the snapshot updates, so -// the backend can re-publish its registryRevision PROP and the UI can re-fetch. +// Source, resolved per refresh() (local-replaces-remote): +// * If TOKENS_CONFIG / AMM_POOLS_CONFIG are set, the local JSON files (dev / +// local-sequencer testing) — parsed synchronously. +// * Else if AMM_REGISTRY_URL is set, a remote GitHub registry: a manifest +// (registry.json) naming the tokens/pools files and the deployment the list +// targets. Fetched asynchronously (QNetworkAccessManager); an on-disk cache +// is served meanwhile (stale-while-revalidate), and a manifest whose +// programIds don't match this app's deployment is rejected. // -// A later phase adds a remote GitHub registry (AMM_REGISTRY_URL) that refreshes -// asynchronously; this snapshot/consumer contract stays the same — only the -// body of refresh() changes. +// refresh() bumps revision() and emits changed() whenever the snapshot updates, +// so the backend re-publishes registryRevision and the UI re-fetches. class RegistryLoader : public QObject { Q_OBJECT public: explicit RegistryLoader(QObject* parent = nullptr); - // Current snapshot. Safe to call from the backend's QtRO slots. QVariantList tokens() const { return m_tokens; } QVariantList pools() const { return m_pools; } - // Increments on every snapshot update — the value published as - // AmmUiBackend::registryRevision so QML replicas re-fetch the lists. int revision() const { return m_revision; } + // Where the current snapshot came from: "local" | "remote" | "cache" | "none". + QString source() const { return m_source; } + + // The program ids the app is connected to (base58, from configAccount()), + // used to reject a remote manifest built for a different deployment. Empty + // ⇒ the guard is skipped (permissive). + void setExpectedProgramIds(const QString& ammProgramId, const QString& tokenProgramId); + + // Whether a local-file source (TOKENS_CONFIG / AMM_POOLS_CONFIG) is + // configured — it takes precedence over the remote registry. The backend + // uses this to skip the (sequencer-touching) deployment-guard read when the + // remote source won't be used anyway. + static bool hasLocalSource(); public slots: - // (Re)load the configured source into the snapshot. Synchronous for the - // local-file source; emits changed() when the snapshot has been refreshed. void refresh(); signals: void changed(); private: + void loadLocal(); + + void startRemote(const QUrl& manifestUrl); + void fetchLists(const QUrl& tokensUrl, const QUrl& poolsUrl, const QString& stamp, + quint64 generation); + // manifest deployment guard: true ⇒ ok to apply the remote list. + bool deploymentMatches(const class QJsonObject& manifest) const; + + void publish(const QVariantList& tokens, const QVariantList& pools, const QString& source); + + void loadDiskCache(const QString& url); + void saveDiskCache(const QString& url, const QString& stamp) const; + static QString cachePath(); + + QNetworkAccessManager* nam(); + QVariantList m_tokens; QVariantList m_pools; int m_revision = 0; + QString m_source = QStringLiteral("none"); + + QString m_expectedAmmProgramId; + QString m_expectedTokenProgramId; + + // Manifest freshness stamp (its `timestamp`) currently reflected in the + // snapshot — lets a revalidation skip re-downloading unchanged lists. + QString m_stamp; + // Guards against overlapping refreshes: a reply from an older refresh is + // dropped once a newer refresh has started. + quint64 m_generation = 0; + + QNetworkAccessManager* m_nam = nullptr; // lazily created }; #endif // AMM_UI_REGISTRY_LOADER_H From c3818775f742f40c1ca75ca4eaaebfee77e8b3c0 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:12:40 +0200 Subject: [PATCH 04/11] feat(apps/amm): multi-network single-file registry Replace the per-network manifest + separate tokens.json/pools.json with a single Uniswap-token-list-style document: { networks:[{id,name,programIds}], tokens:[{network,...}], pools:[{network,...}] }. RegistryLoader now fetches one file, selects the active network, and filters entries to it. - Active network = AMM_NETWORK (override) -> else the network whose programIds match the connected deployment (configAccount) -> else the lone network. The programIds match doubles as the deployment guard. - Registry lives in-repo at artifacts/amm-registry.json (no separate repo yet); remove apps/amm/registry-sample/. - Rename setExpectedProgramIds -> setConnectedProgramIds (drives selection + guard); add activeNetwork(). Local dev source (bare arrays) is unchanged. --- apps/amm/registry-sample/README.md | 57 ----- apps/amm/registry-sample/pools.json | 1 - apps/amm/registry-sample/registry.json | 12 - apps/amm/registry-sample/tokens.json | 1 - apps/amm/src/AmmUiBackend.cpp | 2 +- apps/amm/src/RegistryLoader.cpp | 299 +++++++++++++------------ apps/amm/src/RegistryLoader.h | 64 +++--- artifacts/amm-registry.json | 49 ++++ 8 files changed, 244 insertions(+), 241 deletions(-) delete mode 100644 apps/amm/registry-sample/README.md delete mode 100644 apps/amm/registry-sample/pools.json delete mode 100644 apps/amm/registry-sample/registry.json delete mode 100644 apps/amm/registry-sample/tokens.json create mode 100644 artifacts/amm-registry.json diff --git a/apps/amm/registry-sample/README.md b/apps/amm/registry-sample/README.md deleted file mode 100644 index d743dbe9..00000000 --- a/apps/amm/registry-sample/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# AMM registry — sample - -A minimal remote **known-tokens / known-pools registry** for the AMM app, so you -can test the remote-loading path (`AMM_REGISTRY_URL`) end to end. See -`docs/amm-registry-plan.md` for the full design. - -This sample deliberately ships **empty** `tokens.json` / `pools.json` — the first -thing to verify is that the app loads and behaves sanely when the registry -resolves successfully but is empty. - -## Files - -| File | Role | -|---|---| -| `registry.json` | The **manifest** `AMM_REGISTRY_URL` points at. Names the token/pool files and the deployment the list targets. | -| `tokens.json` | The known-tokens array (currently `[]`). | -| `pools.json` | The known-pools array (currently `[]`). | - -`tokensUrl` / `poolsUrl` in the manifest are resolved **relative to the manifest -URL**, so all three files just need to sit in the same directory. - -## How to test - -1. Push this directory on a branch of your fork/repo (e.g. `logos-blockchain/lez-programs`). -2. Point the app at the manifest's **raw** URL, and make sure the local-file - overrides are unset (a local `TOKENS_CONFIG` / `AMM_POOLS_CONFIG` replaces the - remote source entirely): - - ```bash - unset TOKENS_CONFIG AMM_POOLS_CONFIG - AMM_REGISTRY_URL=https://raw.githubusercontent.com//lez-programs//apps/amm/registry-sample/registry.json \ - nix run .#amm-ui - ``` - -The app fetches `registry.json`, then `tokens.json` + `pools.json`, caches them -under the app's data dir, and shows the (empty) lists. A failed/unreachable fetch -falls back to the last cached copy. - -## Manifest fields - -- `name`, `version`, `timestamp`, `network` — informational. `timestamp` is the - freshness key: **bump it whenever you edit `tokens.json` / `pools.json`** so the - app re-downloads them instead of serving its cache. -- `tokensUrl`, `poolsUrl` — required; relative to the manifest URL. -- `programIds: { amm, token }` — the deployment this list targets. Left **empty** - here so the sample loads against any deployment. Fill them in (base58, from - `spel inspect` / the app's `configAccount`) to enable the **deployment guard**: - the app then rejects the list unless these match the AMM/token programs it's - connected to — which prevents showing stale IDs after a redeploy. - -## Adding tokens / pools later - -`tokens.json` entries: `{ symbol, name, definitionId, decimals }` (base58 ids; -`holding` is per-wallet and resolved by the app, so it is **not** in the shared -list). `pools.json` entries: `{ tokenA, tokenB, feeBps, poolId, -tokenADefinitionId, tokenBDefinitionId }`. Malformed entries are skipped, not -fatal. Remember to bump `timestamp`. diff --git a/apps/amm/registry-sample/pools.json b/apps/amm/registry-sample/pools.json deleted file mode 100644 index fe51488c..00000000 --- a/apps/amm/registry-sample/pools.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/apps/amm/registry-sample/registry.json b/apps/amm/registry-sample/registry.json deleted file mode 100644 index 4e50720c..00000000 --- a/apps/amm/registry-sample/registry.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "AMM registry (sample)", - "version": "0.1.0", - "timestamp": "2026-01-01T00:00:00Z", - "network": "testnet", - "programIds": { - "amm": "", - "token": "" - }, - "tokensUrl": "tokens.json", - "poolsUrl": "pools.json" -} diff --git a/apps/amm/registry-sample/tokens.json b/apps/amm/registry-sample/tokens.json deleted file mode 100644 index fe51488c..00000000 --- a/apps/amm/registry-sample/tokens.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index d1ba00e2..e0d383e5 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -52,7 +52,7 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) if (!RegistryLoader::hasLocalSource()) { const QVariantMap cfg = m_logos->amm_module.configAccount(); if (cfg.value(QStringLiteral("status")).toString() == QStringLiteral("ok")) { - m_registry->setExpectedProgramIds( + m_registry->setConnectedProgramIds( cfg.value(QStringLiteral("ammProgramId")).toString(), cfg.value(QStringLiteral("tokenProgramId")).toString()); } diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp index de617612..34d0cfdd 100644 --- a/apps/amm/src/RegistryLoader.cpp +++ b/apps/amm/src/RegistryLoader.cpp @@ -18,31 +18,64 @@ #include namespace { - // Local-file source (dev / local-sequencer). Takes precedence over the - // remote registry when either is set. + // Local-file source (dev / local-sequencer). Bare `[...]` arrays; takes + // precedence over the remote registry when either is set. constexpr char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; constexpr char POOLS_CONFIG_ENV[] = "AMM_POOLS_CONFIG"; - // Remote source: the URL of a registry manifest (registry.json). + // Remote source: the URL of a single multi-network registry document. constexpr char REGISTRY_URL_ENV[] = "AMM_REGISTRY_URL"; - - // Parses the pools JSON payload into the QVariantList the Pools UI renders. - // Source-agnostic (local file or remote payload). Fails soft (empty list) - // when the payload is not a JSON array — one malformed entry is skipped - // rather than dropping the whole list. tokenA/tokenB (display symbols) and a - // numeric feeBps are required; the id fields pass through when present so the - // entry can later be resolved on-chain. - QVariantList parsePoolsJson(const QByteArray& bytes) + // Optional: force the active network by id (else it is inferred, see + // RegistryLoader::selectActiveNetwork). + constexpr char NETWORK_ENV[] = "AMM_NETWORK"; + + // Parses a tokens array into the QVariantList the Swap token picker renders, + // keeping only entries for `networkFilter` (empty ⇒ keep all, for local files + // which carry no network tag). Fail-soft: one malformed entry is skipped. + // symbol/name are display; definitionId is the token's account id and passes + // through as configured (base58 or hex). `holding` is per-wallet and absent + // from a shared registry — the app resolves it — so only definitionId and a + // valid decimals are required. + QVariantList parseTokens(const QJsonArray& arr, const QString& networkFilter) { QVariantList out; + for (const QJsonValue& entry : arr) { + if (!entry.isObject()) + continue; + const QJsonObject obj = entry.toObject(); + if (!networkFilter.isEmpty() + && obj.value(QStringLiteral("network")).toString() != networkFilter) + continue; - const QJsonDocument doc = QJsonDocument::fromJson(bytes); - if (!doc.isArray()) - return out; + const QString definitionId = obj.value(QStringLiteral("definitionId")).toString(); + const QJsonValue decimals = obj.value(QStringLiteral("decimals")); + if (definitionId.isEmpty() || !decimals.isDouble()) + continue; - for (const QJsonValue& entry : doc.array()) { + QVariantMap token; + token.insert(QStringLiteral("symbol"), obj.value(QStringLiteral("symbol")).toString()); + token.insert(QStringLiteral("name"), obj.value(QStringLiteral("name")).toString()); + token.insert(QStringLiteral("definitionId"), definitionId); + token.insert(QStringLiteral("holding"), obj.value(QStringLiteral("holding")).toString()); + token.insert(QStringLiteral("decimals"), decimals.toInt()); + out.append(token); + } + return out; + } + + // Parses a pools array into the QVariantList the Pools UI renders, keeping + // only entries for `networkFilter` (empty ⇒ keep all). tokenA/tokenB (display + // symbols) and a numeric feeBps are required; the id fields pass through when + // present so the entry can be resolved on-chain. + QVariantList parsePools(const QJsonArray& arr, const QString& networkFilter) + { + QVariantList out; + for (const QJsonValue& entry : arr) { if (!entry.isObject()) continue; const QJsonObject obj = entry.toObject(); + if (!networkFilter.isEmpty() + && obj.value(QStringLiteral("network")).toString() != networkFilter) + continue; const QString tokenA = obj.value(QStringLiteral("tokenA")).toString(); const QString tokenB = obj.value(QStringLiteral("tokenB")).toString(); @@ -65,58 +98,22 @@ namespace { return out; } - // Parses the tokens JSON payload into the QVariantList the Swap token picker - // renders. Same fail-soft, skip-malformed-entry behavior as parsePoolsJson(). - // symbol/name are display; definitionId/holding are the token's account ids - // and pass through as configured (base58 or hex) — the module methods - // normalize to hex at their boundary. decimals must be a non-negative integer - // (a wrong value would misrender amounts). - QVariantList parseTokensJson(const QByteArray& bytes) - { - QVariantList out; - - const QJsonDocument doc = QJsonDocument::fromJson(bytes); - if (!doc.isArray()) - return out; - - for (const QJsonValue& entry : doc.array()) { - if (!entry.isObject()) - continue; - const QJsonObject obj = entry.toObject(); - - const QString definitionId = obj.value(QStringLiteral("definitionId")).toString(); - const QString holding = obj.value(QStringLiteral("holding")).toString(); - const QJsonValue decimals = obj.value(QStringLiteral("decimals")); - // holding is per-wallet and absent from a shared remote list; only - // definitionId + a valid decimals are required for a token to render. - if (definitionId.isEmpty() || !decimals.isDouble()) - continue; - - QVariantMap token; - token.insert(QStringLiteral("symbol"), obj.value(QStringLiteral("symbol")).toString()); - token.insert(QStringLiteral("name"), obj.value(QStringLiteral("name")).toString()); - token.insert(QStringLiteral("definitionId"), definitionId); - token.insert(QStringLiteral("holding"), holding); - token.insert(QStringLiteral("decimals"), decimals.toInt()); - out.append(token); - } - return out; - } - - // Reads a local JSON file at an env-var path. Returns empty on - // unset/unreadable — callers fail soft. QByteArray readConfigFileBytes(const char* envVar) { const QString path = qEnvironmentVariable(envVar); if (path.isEmpty()) return {}; - QFile file(path); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; - return file.readAll(); } + + QJsonArray jsonArrayFromBytes(const QByteArray& bytes) + { + const QJsonDocument doc = QJsonDocument::fromJson(bytes); + return doc.isArray() ? doc.array() : QJsonArray{}; + } } RegistryLoader::RegistryLoader(QObject* parent) @@ -130,17 +127,16 @@ bool RegistryLoader::hasLocalSource() || !qEnvironmentVariableIsEmpty(POOLS_CONFIG_ENV); } -void RegistryLoader::setExpectedProgramIds(const QString& ammProgramId, - const QString& tokenProgramId) +void RegistryLoader::setConnectedProgramIds(const QString& ammProgramId, + const QString& tokenProgramId) { - m_expectedAmmProgramId = ammProgramId; - m_expectedTokenProgramId = tokenProgramId; + m_connectedAmm = ammProgramId; + m_connectedToken = tokenProgramId; } void RegistryLoader::refresh() { - // Supersede any in-flight remote fetch (a reply from an older generation is - // dropped in its finished handler). + // Supersede any in-flight remote fetch. ++m_generation; // local-replaces-remote: a configured local file wins outright. @@ -151,7 +147,7 @@ void RegistryLoader::refresh() const QString url = qEnvironmentVariable(REGISTRY_URL_ENV); if (url.isEmpty()) { - publish({}, {}, QStringLiteral("none")); + publish({}, {}, QStringLiteral("none"), {}); return; } @@ -165,109 +161,129 @@ void RegistryLoader::refresh() void RegistryLoader::loadLocal() { - publish(parseTokensJson(readConfigFileBytes(TOKENS_CONFIG_ENV)), - parsePoolsJson(readConfigFileBytes(POOLS_CONFIG_ENV)), - QStringLiteral("local")); + // Local files are bare arrays with no network tag — no filtering. + publish(parseTokens(jsonArrayFromBytes(readConfigFileBytes(TOKENS_CONFIG_ENV)), {}), + parsePools(jsonArrayFromBytes(readConfigFileBytes(POOLS_CONFIG_ENV)), {}), + QStringLiteral("local"), {}); } -void RegistryLoader::startRemote(const QUrl& manifestUrl) +void RegistryLoader::startRemote(const QUrl& url) { const quint64 generation = m_generation; - QNetworkReply* reply = nam()->get(QNetworkRequest(manifestUrl)); - connect(reply, &QNetworkReply::finished, this, [this, reply, manifestUrl, generation]() { + QNetworkReply* reply = nam()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply, generation]() { reply->deleteLater(); if (generation != m_generation) return; // superseded by a newer refresh if (reply->error() != QNetworkReply::NoError) { - qWarning() << "AMM registry: manifest fetch failed:" << reply->errorString(); + qWarning() << "AMM registry: fetch failed:" << reply->errorString(); return; // keep serving whatever we have (cache / previous) } - const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); - if (!doc.isObject()) { - qWarning() << "AMM registry: manifest is not a JSON object"; - return; - } - const QJsonObject manifest = doc.object(); - if (!deploymentMatches(manifest)) { - qWarning() << "AMM registry: manifest targets a different deployment; ignoring"; - return; - } - - const QString stamp = manifest.value(QStringLiteral("timestamp")).toVariant().toString(); - // Revalidation: an unchanged manifest with a non-empty snapshot means the - // cached lists are already current — skip re-downloading them. + const QByteArray body = reply->readAll(); + const QString stamp = QJsonDocument::fromJson(body) + .object() + .value(QStringLiteral("timestamp")) + .toVariant() + .toString(); + // Revalidation: an unchanged registry with a non-empty snapshot is + // already current. if (!stamp.isEmpty() && stamp == m_stamp && !m_tokens.isEmpty()) return; - const QString tokensRel = manifest.value(QStringLiteral("tokensUrl")).toString(); - const QString poolsRel = manifest.value(QStringLiteral("poolsUrl")).toString(); - if (tokensRel.isEmpty() || poolsRel.isEmpty()) { - qWarning() << "AMM registry: manifest missing tokensUrl/poolsUrl"; - return; + if (applyRegistry(body, QStringLiteral("remote"))) { + m_stamp = stamp; + saveDiskCache(qEnvironmentVariable(REGISTRY_URL_ENV), stamp, body); } - fetchLists(manifestUrl.resolved(QUrl(tokensRel)), - manifestUrl.resolved(QUrl(poolsRel)), stamp, generation); }); } -void RegistryLoader::fetchLists(const QUrl& tokensUrl, const QUrl& poolsUrl, - const QString& stamp, quint64 generation) +bool RegistryLoader::applyRegistry(const QByteArray& body, const QString& source) { - // Fetch the two lists in sequence, then publish both together so the UI - // never sees tokens without their pools (or vice versa). - QNetworkReply* tokensReply = nam()->get(QNetworkRequest(tokensUrl)); - connect(tokensReply, &QNetworkReply::finished, this, - [this, tokensReply, poolsUrl, stamp, generation]() { - tokensReply->deleteLater(); - if (generation != m_generation) - return; - if (tokensReply->error() != QNetworkReply::NoError) { - qWarning() << "AMM registry: tokens fetch failed:" << tokensReply->errorString(); - return; + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (!doc.isObject()) { + qWarning() << "AMM registry: document is not a JSON object"; + return false; + } + const QJsonObject registry = doc.object(); + const QJsonArray networks = registry.value(QStringLiteral("networks")).toArray(); + + const QString activeId = selectActiveNetwork(networks); + if (activeId.isEmpty()) { + qWarning() << "AMM registry: cannot determine the active network; not applied"; + return false; + } + + QJsonObject activeNetwork; + for (const QJsonValue& entry : networks) { + if (entry.toObject().value(QStringLiteral("id")).toString() == activeId) { + activeNetwork = entry.toObject(); + break; } - const QVariantList tokens = parseTokensJson(tokensReply->readAll()); - - QNetworkReply* poolsReply = nam()->get(QNetworkRequest(poolsUrl)); - connect(poolsReply, &QNetworkReply::finished, this, - [this, poolsReply, tokens, stamp, generation]() { - poolsReply->deleteLater(); - if (generation != m_generation) - return; - if (poolsReply->error() != QNetworkReply::NoError) { - qWarning() << "AMM registry: pools fetch failed:" << poolsReply->errorString(); - return; - } - const QVariantList pools = parsePoolsJson(poolsReply->readAll()); - m_stamp = stamp; - publish(tokens, pools, QStringLiteral("remote")); - saveDiskCache(qEnvironmentVariable(REGISTRY_URL_ENV), stamp); - }); - }); + } + if (!deploymentOk(activeNetwork)) { + qWarning() << "AMM registry: network" << activeId + << "targets a different deployment; ignoring"; + return false; + } + + publish(parseTokens(registry.value(QStringLiteral("tokens")).toArray(), activeId), + parsePools(registry.value(QStringLiteral("pools")).toArray(), activeId), + source, activeId); + return true; } -bool RegistryLoader::deploymentMatches(const QJsonObject& manifest) const +QString RegistryLoader::selectActiveNetwork(const QJsonArray& networks) const { - // No expected ids ⇒ nothing to check against (permissive). - if (m_expectedAmmProgramId.isEmpty() && m_expectedTokenProgramId.isEmpty()) - return true; + // 1. Explicit override, if it names a network the registry declares. + const QString forced = qEnvironmentVariable(NETWORK_ENV); + if (!forced.isEmpty()) { + for (const QJsonValue& entry : networks) { + if (entry.toObject().value(QStringLiteral("id")).toString() == forced) + return forced; + } + return {}; + } + + // 2. The network whose programIds match the deployment we're connected to. + if (!m_connectedAmm.isEmpty() || !m_connectedToken.isEmpty()) { + for (const QJsonValue& entry : networks) { + const QJsonObject ids = + entry.toObject().value(QStringLiteral("programIds")).toObject(); + if (ids.value(QStringLiteral("amm")).toString() == m_connectedAmm + && ids.value(QStringLiteral("token")).toString() == m_connectedToken) + return entry.toObject().value(QStringLiteral("id")).toString(); + } + } + + // 3. A registry that declares exactly one network is unambiguous. + if (networks.size() == 1) + return networks.at(0).toObject().value(QStringLiteral("id")).toString(); - const QJsonObject ids = manifest.value(QStringLiteral("programIds")).toObject(); + return {}; +} + +bool RegistryLoader::deploymentOk(const QJsonObject& network) const +{ + const QJsonObject ids = network.value(QStringLiteral("programIds")).toObject(); const QString amm = ids.value(QStringLiteral("amm")).toString(); const QString token = ids.value(QStringLiteral("token")).toString(); - // A manifest that doesn't declare a deployment is trusted (the operator - // chose the URL); the guard only rejects a declared, mismatched deployment. + // A network that doesn't declare its deployment is trusted (the operator + // chose the URL); and with no connection info we cannot check. if (amm.isEmpty() && token.isEmpty()) return true; - return amm == m_expectedAmmProgramId && token == m_expectedTokenProgramId; + if (m_connectedAmm.isEmpty() && m_connectedToken.isEmpty()) + return true; + return amm == m_connectedAmm && token == m_connectedToken; } void RegistryLoader::publish(const QVariantList& tokens, const QVariantList& pools, - const QString& source) + const QString& source, const QString& network) { m_tokens = tokens; m_pools = pools; m_source = source; + m_activeNetwork = network; ++m_revision; emit changed(); } @@ -277,21 +293,21 @@ void RegistryLoader::loadDiskCache(const QString& url) QFile file(cachePath()); if (!file.open(QIODevice::ReadOnly)) return; - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (!doc.isObject()) - return; - const QJsonObject obj = doc.object(); + const QJsonObject obj = QJsonDocument::fromJson(file.readAll()).object(); // Only trust a cache written for this same source URL. if (obj.value(QStringLiteral("url")).toString() != url) return; m_stamp = obj.value(QStringLiteral("stamp")).toVariant().toString(); - publish(obj.value(QStringLiteral("tokens")).toArray().toVariantList(), - obj.value(QStringLiteral("pools")).toArray().toVariantList(), - QStringLiteral("cache")); + // Re-apply the cached registry against the current connection (the active + // network may resolve differently than when it was written). + const QByteArray body = + QJsonDocument(obj.value(QStringLiteral("registry")).toObject()).toJson(QJsonDocument::Compact); + applyRegistry(body, QStringLiteral("cache")); } -void RegistryLoader::saveDiskCache(const QString& url, const QString& stamp) const +void RegistryLoader::saveDiskCache(const QString& url, const QString& stamp, + const QByteArray& body) const { const QString path = cachePath(); QDir().mkpath(QFileInfo(path).absolutePath()); @@ -299,8 +315,7 @@ void RegistryLoader::saveDiskCache(const QString& url, const QString& stamp) con QJsonObject obj; obj.insert(QStringLiteral("url"), url); obj.insert(QStringLiteral("stamp"), stamp); - obj.insert(QStringLiteral("tokens"), QJsonArray::fromVariantList(m_tokens)); - obj.insert(QStringLiteral("pools"), QJsonArray::fromVariantList(m_pools)); + obj.insert(QStringLiteral("registry"), QJsonDocument::fromJson(body).object()); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) diff --git a/apps/amm/src/RegistryLoader.h b/apps/amm/src/RegistryLoader.h index 8c6af22c..6ecbba1f 100644 --- a/apps/amm/src/RegistryLoader.h +++ b/apps/amm/src/RegistryLoader.h @@ -7,19 +7,25 @@ #include class QNetworkAccessManager; -class QNetworkReply; +class QJsonArray; +class QJsonObject; // Loads the AMM app's "known tokens" and "known pools" and serves them as an // in-memory snapshot the backend's QtRO slots read synchronously. // // Source, resolved per refresh() (local-replaces-remote): -// * If TOKENS_CONFIG / AMM_POOLS_CONFIG are set, the local JSON files (dev / -// local-sequencer testing) — parsed synchronously. -// * Else if AMM_REGISTRY_URL is set, a remote GitHub registry: a manifest -// (registry.json) naming the tokens/pools files and the deployment the list -// targets. Fetched asynchronously (QNetworkAccessManager); an on-disk cache -// is served meanwhile (stale-while-revalidate), and a manifest whose -// programIds don't match this app's deployment is rejected. +// * If TOKENS_CONFIG / AMM_POOLS_CONFIG are set, the local JSON files (bare +// `[...]` arrays, dev / local-sequencer testing) — parsed synchronously. +// * Else if AMM_REGISTRY_URL is set, a single remote registry document +// (Uniswap-token-list style, multi-network): `{ networks:[{id, programIds}], +// tokens:[{network, ...}], pools:[{network, ...}] }`. Fetched asynchronously +// (QNetworkAccessManager) with an on-disk cache served meanwhile +// (stale-while-revalidate). Entries are filtered to the active network. +// +// Active network = AMM_NETWORK if set, else the registry network whose programIds +// match the deployment the app is connected to (see setConnectedProgramIds), else +// the lone network when the registry declares exactly one. A registry that names a +// network whose programIds contradict the app's deployment is rejected. // // refresh() bumps revision() and emits changed() whenever the snapshot updates, // so the backend re-publishes registryRevision and the UI re-fetches. @@ -34,16 +40,18 @@ class RegistryLoader : public QObject { int revision() const { return m_revision; } // Where the current snapshot came from: "local" | "remote" | "cache" | "none". QString source() const { return m_source; } + // The network id the snapshot was filtered to (empty for local / none). + QString activeNetwork() const { return m_activeNetwork; } - // The program ids the app is connected to (base58, from configAccount()), - // used to reject a remote manifest built for a different deployment. Empty - // ⇒ the guard is skipped (permissive). - void setExpectedProgramIds(const QString& ammProgramId, const QString& tokenProgramId); + // The deployment the app is connected to (base58 program ids, from + // configAccount()): used to pick the matching network in a multi-network + // registry and to reject a registry whose active network contradicts it. + // Empty ⇒ selection falls back to AMM_NETWORK / a lone network. + void setConnectedProgramIds(const QString& ammProgramId, const QString& tokenProgramId); // Whether a local-file source (TOKENS_CONFIG / AMM_POOLS_CONFIG) is - // configured — it takes precedence over the remote registry. The backend - // uses this to skip the (sequencer-touching) deployment-guard read when the - // remote source won't be used anyway. + // configured — it takes precedence over the remote registry. The backend uses + // this to skip the sequencer-touching configAccount read for local dev. static bool hasLocalSource(); public slots: @@ -54,17 +62,18 @@ public slots: private: void loadLocal(); + void startRemote(const QUrl& url); + // Parse the registry body, select + guard the active network, filter, and + // publish. Returns true when a snapshot was applied. + bool applyRegistry(const QByteArray& body, const QString& source); + QString selectActiveNetwork(const QJsonArray& networks) const; + bool deploymentOk(const QJsonObject& network) const; - void startRemote(const QUrl& manifestUrl); - void fetchLists(const QUrl& tokensUrl, const QUrl& poolsUrl, const QString& stamp, - quint64 generation); - // manifest deployment guard: true ⇒ ok to apply the remote list. - bool deploymentMatches(const class QJsonObject& manifest) const; - - void publish(const QVariantList& tokens, const QVariantList& pools, const QString& source); + void publish(const QVariantList& tokens, const QVariantList& pools, + const QString& source, const QString& network); void loadDiskCache(const QString& url); - void saveDiskCache(const QString& url, const QString& stamp) const; + void saveDiskCache(const QString& url, const QString& stamp, const QByteArray& body) const; static QString cachePath(); QNetworkAccessManager* nam(); @@ -73,12 +82,13 @@ public slots: QVariantList m_pools; int m_revision = 0; QString m_source = QStringLiteral("none"); + QString m_activeNetwork; - QString m_expectedAmmProgramId; - QString m_expectedTokenProgramId; + QString m_connectedAmm; + QString m_connectedToken; - // Manifest freshness stamp (its `timestamp`) currently reflected in the - // snapshot — lets a revalidation skip re-downloading unchanged lists. + // Registry `timestamp` reflected in the snapshot — lets a revalidation skip + // re-applying an unchanged document. QString m_stamp; // Guards against overlapping refreshes: a reply from an older refresh is // dropped once a newer refresh has started. diff --git a/artifacts/amm-registry.json b/artifacts/amm-registry.json new file mode 100644 index 00000000..f4fd1648 --- /dev/null +++ b/artifacts/amm-registry.json @@ -0,0 +1,49 @@ +{ + "name": "Logos AMM registry", + "version": "0.1.0", + "timestamp": "2026-08-27T00:00:00Z", + "networks": [ + { + "id": "testnet", + "name": "Testnet", + "programIds": { + "amm": "", + "token": "" + } + } + ], + "tokens": [ + { + "network": "testnet", + "symbol": "TKA", + "name": "TOKEN A", + "definitionId": "Eiw5zDP1BKukkxMY8dj7Hkw9NfCTh6iU5av5F7FT8ExC", + "decimals": 18 + }, + { + "network": "testnet", + "symbol": "TKB", + "name": "TOKEN B", + "definitionId": "HGcyQfm45BQ39iWXZND5ZPBKCenrhfGyhjcLE2t63RKi", + "decimals": 18 + }, + { + "network": "testnet", + "symbol": "TKC", + "name": "TOKEN C", + "definitionId": "G7bFoYceBmFtsCNWL89nm86aigvybuo5Jm9v5xGkD73c", + "decimals": 18 + } + ], + "pools": [ + { + "network": "testnet", + "tokenA": "TKA", + "tokenB": "TKB", + "feeBps": 1, + "poolId": "2imc6PrWbLnnuWgNpw6QUp6YkH8xbfLtWG57RxUBxEkS", + "tokenADefinitionId": "Eiw5zDP1BKukkxMY8dj7Hkw9NfCTh6iU5av5F7FT8ExC", + "tokenBDefinitionId": "HGcyQfm45BQ39iWXZND5ZPBKCenrhfGyhjcLE2t63RKi" + } + ] +} From ef38bb8ffa3fbdb71159b920912d4673ecae211b Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:13:29 +0200 Subject: [PATCH 05/11] test(apps/amm): emit a local registry from the AMM testnet setup script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-amm-testnet.sh now also writes tests/testnet/amm-registry.json — the seeded tokens/pools in the single-file multi-network registry shape (one "local" network with empty programIds so the lone-network rule auto-selects it; holding-agnostic tokens; a fresh timestamp each run). This lets the AMM_REGISTRY_URL path be exercised against the local sequencer without hosting anything — point AMM_REGISTRY_URL at the file with a file:// URL. --- apps/amm/.gitignore | 3 ++ apps/amm/src/RegistryLoader.cpp | 6 +-- apps/amm/tests/testnet/setup-amm-testnet.sh | 53 +++++++++++++++++++++ artifacts/amm-registry.json | 47 ++---------------- 4 files changed, 62 insertions(+), 47 deletions(-) diff --git a/apps/amm/.gitignore b/apps/amm/.gitignore index 9d4018ce..8aeb9df7 100644 --- a/apps/amm/.gitignore +++ b/apps/amm/.gitignore @@ -21,6 +21,9 @@ tests/testnet/amm-tokens.json # Isolated known-pools config written by tests/testnet/setup-amm-testnet.sh tests/testnet/amm-pools.json +# Isolated single-file registry (AMM_REGISTRY_URL path) written by the setup script +tests/testnet/amm-registry.json + # Isolated custom-token store (CUSTOM_TOKEN_CONFIG) — initialized by the setup script # and written by the app during tests/custom-token.mjs tests/testnet/custom-tokens.json diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp index 34d0cfdd..48ec4d53 100644 --- a/apps/amm/src/RegistryLoader.cpp +++ b/apps/amm/src/RegistryLoader.cpp @@ -33,8 +33,8 @@ namespace { // which carry no network tag). Fail-soft: one malformed entry is skipped. // symbol/name are display; definitionId is the token's account id and passes // through as configured (base58 or hex). `holding` is per-wallet and absent - // from a shared registry — the app resolves it — so only definitionId and a - // valid decimals are required. + // from a shared registry — the app resolves it — so only definitionId is + // required. `decimals` is optional (the app doesn't use it yet); absent ⇒ 0. QVariantList parseTokens(const QJsonArray& arr, const QString& networkFilter) { QVariantList out; @@ -48,7 +48,7 @@ namespace { const QString definitionId = obj.value(QStringLiteral("definitionId")).toString(); const QJsonValue decimals = obj.value(QStringLiteral("decimals")); - if (definitionId.isEmpty() || !decimals.isDouble()) + if (definitionId.isEmpty()) continue; QVariantMap token; diff --git a/apps/amm/tests/testnet/setup-amm-testnet.sh b/apps/amm/tests/testnet/setup-amm-testnet.sh index 031e3318..4cb28c75 100755 --- a/apps/amm/tests/testnet/setup-amm-testnet.sh +++ b/apps/amm/tests/testnet/setup-amm-testnet.sh @@ -123,6 +123,12 @@ TOKENS_CONFIG_OUT="apps/amm/tests/testnet/amm-tokens.json" # per entry. More seeded pools = more entries here, no app change. POOLS_CONFIG_OUT="apps/amm/tests/testnet/amm-pools.json" +# Single-file multi-network registry (git-ignored, tests only) — the same tokens +# and pools in the remote-registry shape, so the +# AMM_REGISTRY_URL path can be exercised against this local sequencer without +# hosting anything (point AMM_REGISTRY_URL at this file via a file:// URL). +REGISTRY_CONFIG_OUT="apps/amm/tests/testnet/amm-registry.json" + # Isolated custom-token store for TESTS ONLY (git-ignored). Pass this path as # CUSTOM_TOKEN_CONFIG when launching the UI so custom-token.mjs controls it instead of # the app's default per-user store. Initialized empty so a test run starts clean. @@ -534,6 +540,45 @@ JSON } > "$POOLS_CONFIG_OUT" kv "wrote" "$POOLS_CONFIG_OUT" +############################################################################### +# 11b. Write the single-file registry (for testing the AMM_REGISTRY_URL path) +############################################################################### +sec "Write UI registry config -> $REGISTRY_CONFIG_OUT" +# Same tokens/pools in the remote-registry shape: one "local" network, holding- +# agnostic tokens (the app resolves holdings from the wallet). programIds are left +# empty so the lone-network rule auto-selects "local"; the fresh timestamp forces +# a re-fetch each run (ids change per deployment). +{ + cat < "$REGISTRY_CONFIG_OUT" +kv "wrote" "$REGISTRY_CONFIG_OUT" + ############################################################################### # 12. Initialize the isolated custom-token store (empty) ############################################################################### @@ -557,6 +602,14 @@ log " ${DIM} AMM_POOLS_CONFIG=$REPO_ROOT/$POOLS_CONFIG_OUT \\${RST}" log " ${DIM} CUSTOM_TOKEN_CONFIG=$REPO_ROOT/$CUSTOM_TOKEN_CONFIG_OUT \\${RST}" log " ${DIM} nix run .#amm-ui${RST}" log "" +log "Or exercise the remote-registry path against the same sequencer — omit" +log "TOKENS_CONFIG/AMM_POOLS_CONFIG so the local files don't take precedence:" +log " ${DIM}LEE_WALLET_HOME_DIR=$TEST_WALLET_HOME \\${RST}" +log " ${DIM} AMM_PROGRAM_BIN=$REPO_ROOT/$AMM_BIN \\${RST}" +log " ${DIM} AMM_REGISTRY_URL=file://$REPO_ROOT/$REGISTRY_CONFIG_OUT \\${RST}" +log " ${DIM} CUSTOM_TOKEN_CONFIG=$REPO_ROOT/$CUSTOM_TOKEN_CONFIG_OUT \\${RST}" +log " ${DIM} nix run .#amm-ui${RST}" +log "" log "Token D was created ON-CHAIN but left out of the token config (the ${DIM}custom${RST}" log "token). Its id: ${DIM}$TOKEN_D_DEF${RST}" log "" diff --git a/artifacts/amm-registry.json b/artifacts/amm-registry.json index f4fd1648..0d604c3d 100644 --- a/artifacts/amm-registry.json +++ b/artifacts/amm-registry.json @@ -2,48 +2,7 @@ "name": "Logos AMM registry", "version": "0.1.0", "timestamp": "2026-08-27T00:00:00Z", - "networks": [ - { - "id": "testnet", - "name": "Testnet", - "programIds": { - "amm": "", - "token": "" - } - } - ], - "tokens": [ - { - "network": "testnet", - "symbol": "TKA", - "name": "TOKEN A", - "definitionId": "Eiw5zDP1BKukkxMY8dj7Hkw9NfCTh6iU5av5F7FT8ExC", - "decimals": 18 - }, - { - "network": "testnet", - "symbol": "TKB", - "name": "TOKEN B", - "definitionId": "HGcyQfm45BQ39iWXZND5ZPBKCenrhfGyhjcLE2t63RKi", - "decimals": 18 - }, - { - "network": "testnet", - "symbol": "TKC", - "name": "TOKEN C", - "definitionId": "G7bFoYceBmFtsCNWL89nm86aigvybuo5Jm9v5xGkD73c", - "decimals": 18 - } - ], - "pools": [ - { - "network": "testnet", - "tokenA": "TKA", - "tokenB": "TKB", - "feeBps": 1, - "poolId": "2imc6PrWbLnnuWgNpw6QUp6YkH8xbfLtWG57RxUBxEkS", - "tokenADefinitionId": "Eiw5zDP1BKukkxMY8dj7Hkw9NfCTh6iU5av5F7FT8ExC", - "tokenBDefinitionId": "HGcyQfm45BQ39iWXZND5ZPBKCenrhfGyhjcLE2t63RKi" - } - ] + "networks": [], + "tokens": [], + "pools": [] } From be2e56a497f826694fcfa7de069b504c44f6bb31 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:16:40 +0200 Subject: [PATCH 06/11] feat(modules/amm): add setAmmProgramId to select the program id at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module derives its program id from AMM_PROGRAM_BIN. Add setAmmProgramId so a caller can adopt a specific id instead — the app uses it to target the network it selected from its registry, dropping the AMM_PROGRAM_BIN requirement. ammProgramId() prefers the adopted id and falls back to the bin when none is set, so headless callers are unaffected. --- modules/amm/src/amm_module_impl.cpp | 15 +++++++++++++++ modules/amm/src/amm_module_impl.h | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index a16bb654..a342728c 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -246,6 +246,10 @@ std::vector AmmModuleImpl::loadAmmElf() { } std::string AmmModuleImpl::ammProgramId() { + // An app-selected program id (setAmmProgramId) takes precedence; AMM_PROGRAM_BIN + // is the fallback for local / headless / no-registry use. + if (!m_activeProgramId.empty()) return m_activeProgramId; + const std::vector elf = loadAmmElf(); if (elf.empty()) return {}; // Hand the deployed binary to the amm_ffi program_id op, which decodes it @@ -259,6 +263,17 @@ std::string AmmModuleImpl::ammProgramId() { return jStr(r.value, "programId"); } +LogosMap AmmModuleImpl::setAmmProgramId(const LogosMap& request) { + const std::string raw = jStr(request, "ammProgramId"); + const std::string normalized = normalizeAccountId(raw); + if (!raw.empty() && normalized.empty()) + return LogosMap{{"status", "error"}, {"error", "invalid_account_id"}}; + + // Adopt the caller's chosen id (normalized to hex; empty reverts to the bin). + m_activeProgramId = normalized; + return LogosMap{{"status", "ok"}, {"error", ""}}; +} + std::string AmmModuleImpl::normalizeAccountId(const std::string& id) { size_t start = 0; size_t end = id.size(); diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index 0a7a602d..581c5c8e 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -47,6 +47,13 @@ class AmmModuleImpl : public LogosModuleContext { /// `backend_error` when the backend FFI call fails. LogosMap configAccount(); + /// Sets the AMM program id every op derives from (base58 or hex; empty clears + /// it, reverting to `AMM_PROGRAM_BIN`). The app calls this to adopt the program + /// id of the network it selected (from its configured registry) so no + /// `AMM_PROGRAM_BIN` is needed. Not selection logic — the module just adopts the + /// caller's choice. Headless callers never touch it. Returns `{ status:"ok" }`. + LogosMap setAmmProgramId(const LogosMap& request); + /// Submits an `UpdateConfig` transferring admin authority to `request.newAuthorityId` /// (base58 or hex). Only the current admin can sign, so the connected wallet must control it. /// On success `{ status:"ok", error:"", transactionId: }`; on failure: @@ -280,4 +287,8 @@ class AmmModuleImpl : public LogosModuleContext { // Shared body for createPriceObservations / createOraclePriceAccount: reads the config, // builds the window-seeded oracle plan (observations vs price account), and submits it. LogosMap oracleSetupSubmit(const LogosMap& request, bool observations); + + // The AMM program id the app selected (via setAmmProgramId). Empty ⇒ + // ammProgramId() falls back to deriving it from AMM_PROGRAM_BIN. + std::string m_activeProgramId; }; From fde328338ea8354c2dd8356549e2b80085a7d4e4 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:27:03 +0200 Subject: [PATCH 07/11] feat(apps/amm): select the registry network without a program bin RegistryLoader no longer infers the network from the connected deployment (which needed AMM_PROGRAM_BIN and can't tell apart networks that share deterministic program/account ids). It selects by AMM_NETWORK, or the lone network when the registry declares exactly one, and exposes that network's amm program id via activeAmmProgramId(). AmmUiBackend adopts it on the module with setAmmProgramId whenever the snapshot changes, so ops target the selected network with no bin. A multi-network registry now requires AMM_NETWORK to disambiguate. The testnet setup script writes the freshly deployed program ids into the local registry network and drops AMM_PROGRAM_BIN from the remote-registry launch line. --- apps/amm/src/AmmUiBackend.cpp | 21 +++---- apps/amm/src/RegistryLoader.cpp | 62 +++++++-------------- apps/amm/src/RegistryLoader.h | 31 +++++------ apps/amm/tests/testnet/setup-amm-testnet.sh | 11 ++-- 4 files changed, 47 insertions(+), 78 deletions(-) diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index e0d383e5..151a8e32 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -30,9 +30,13 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) { setWalletStateReady(false); - // Bump registryRevision whenever the known-tokens / known-pools snapshot - // refreshes so QML replicas re-fetch tokenList()/poolList()/resolveTokens(). + // Whenever the known-tokens / known-pools snapshot refreshes: adopt the active + // network's AMM program id on the module (empty ⇒ falls back to AMM_PROGRAM_BIN) + // so ops target that network without a bin, then bump registryRevision so QML + // replicas re-fetch tokenList()/poolList()/resolveTokens(). connect(m_registry.get(), &RegistryLoader::changed, this, [this]() { + m_logos->amm_module.setAmmProgramId(QVariantMap{ + {QStringLiteral("ammProgramId"), m_registry->activeAmmProgramId()}}); setRegistryRevision(m_registry->revision()); }); @@ -46,17 +50,8 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) setWalletStateReady(true); syncWalletState(); // Load the registry once the event loop is running (the remote source - // fetches asynchronously). Only the remote source needs the deployment - // guard, so skip the sequencer-touching configAccount read when local - // files are configured (they take precedence anyway). - if (!RegistryLoader::hasLocalSource()) { - const QVariantMap cfg = m_logos->amm_module.configAccount(); - if (cfg.value(QStringLiteral("status")).toString() == QStringLiteral("ok")) { - m_registry->setConnectedProgramIds( - cfg.value(QStringLiteral("ammProgramId")).toString(), - cfg.value(QStringLiteral("tokenProgramId")).toString()); - } - } + // fetches asynchronously). The changed() handler adopts the selected + // network's program id on the module. m_registry->refresh(); }); } diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp index 48ec4d53..418ca306 100644 --- a/apps/amm/src/RegistryLoader.cpp +++ b/apps/amm/src/RegistryLoader.cpp @@ -127,18 +127,15 @@ bool RegistryLoader::hasLocalSource() || !qEnvironmentVariableIsEmpty(POOLS_CONFIG_ENV); } -void RegistryLoader::setConnectedProgramIds(const QString& ammProgramId, - const QString& tokenProgramId) -{ - m_connectedAmm = ammProgramId; - m_connectedToken = tokenProgramId; -} - void RegistryLoader::refresh() { // Supersede any in-flight remote fetch. ++m_generation; + // No adopted network id until applyRegistry selects one; the local / none paths + // below carry none, so ops fall back to AMM_PROGRAM_BIN. + m_activeAmmProgramId.clear(); + // local-replaces-remote: a configured local file wins outright. if (hasLocalSource()) { loadLocal(); @@ -210,22 +207,24 @@ bool RegistryLoader::applyRegistry(const QByteArray& body, const QString& source const QString activeId = selectActiveNetwork(networks); if (activeId.isEmpty()) { - qWarning() << "AMM registry: cannot determine the active network; not applied"; + qWarning() << "AMM registry: cannot determine the active network" + " (set AMM_NETWORK for a multi-network registry); not applied"; return false; } - QJsonObject activeNetwork; + // Adopt the active network's declared AMM program id so the backend can point + // ops at it (setAmmProgramId) without an AMM_PROGRAM_BIN. + m_activeAmmProgramId.clear(); for (const QJsonValue& entry : networks) { - if (entry.toObject().value(QStringLiteral("id")).toString() == activeId) { - activeNetwork = entry.toObject(); + const QJsonObject net = entry.toObject(); + if (net.value(QStringLiteral("id")).toString() == activeId) { + m_activeAmmProgramId = net.value(QStringLiteral("programIds")) + .toObject() + .value(QStringLiteral("amm")) + .toString(); break; } } - if (!deploymentOk(activeNetwork)) { - qWarning() << "AMM registry: network" << activeId - << "targets a different deployment; ignoring"; - return false; - } publish(parseTokens(registry.value(QStringLiteral("tokens")).toArray(), activeId), parsePools(registry.value(QStringLiteral("pools")).toArray(), activeId), @@ -235,7 +234,7 @@ bool RegistryLoader::applyRegistry(const QByteArray& body, const QString& source QString RegistryLoader::selectActiveNetwork(const QJsonArray& networks) const { - // 1. Explicit override, if it names a network the registry declares. + // Explicit override wins if it names a declared network. const QString forced = qEnvironmentVariable(NETWORK_ENV); if (!forced.isEmpty()) { for (const QJsonValue& entry : networks) { @@ -245,38 +244,15 @@ QString RegistryLoader::selectActiveNetwork(const QJsonArray& networks) const return {}; } - // 2. The network whose programIds match the deployment we're connected to. - if (!m_connectedAmm.isEmpty() || !m_connectedToken.isEmpty()) { - for (const QJsonValue& entry : networks) { - const QJsonObject ids = - entry.toObject().value(QStringLiteral("programIds")).toObject(); - if (ids.value(QStringLiteral("amm")).toString() == m_connectedAmm - && ids.value(QStringLiteral("token")).toString() == m_connectedToken) - return entry.toObject().value(QStringLiteral("id")).toString(); - } - } - - // 3. A registry that declares exactly one network is unambiguous. + // A single declared network is unambiguous. Multiple networks can't be told + // apart from the connection (program ids and account ids are deterministic and + // may be identical across networks), so AMM_NETWORK is required to pick one. if (networks.size() == 1) return networks.at(0).toObject().value(QStringLiteral("id")).toString(); return {}; } -bool RegistryLoader::deploymentOk(const QJsonObject& network) const -{ - const QJsonObject ids = network.value(QStringLiteral("programIds")).toObject(); - const QString amm = ids.value(QStringLiteral("amm")).toString(); - const QString token = ids.value(QStringLiteral("token")).toString(); - // A network that doesn't declare its deployment is trusted (the operator - // chose the URL); and with no connection info we cannot check. - if (amm.isEmpty() && token.isEmpty()) - return true; - if (m_connectedAmm.isEmpty() && m_connectedToken.isEmpty()) - return true; - return amm == m_connectedAmm && token == m_connectedToken; -} - void RegistryLoader::publish(const QVariantList& tokens, const QVariantList& pools, const QString& source, const QString& network) { diff --git a/apps/amm/src/RegistryLoader.h b/apps/amm/src/RegistryLoader.h index 6ecbba1f..5b5c1337 100644 --- a/apps/amm/src/RegistryLoader.h +++ b/apps/amm/src/RegistryLoader.h @@ -22,10 +22,13 @@ class QJsonObject; // (QNetworkAccessManager) with an on-disk cache served meanwhile // (stale-while-revalidate). Entries are filtered to the active network. // -// Active network = AMM_NETWORK if set, else the registry network whose programIds -// match the deployment the app is connected to (see setConnectedProgramIds), else -// the lone network when the registry declares exactly one. A registry that names a -// network whose programIds contradict the app's deployment is rejected. +// Active network = AMM_NETWORK if it names a declared network, else the lone +// network when the registry declares exactly one. Network identity can't be +// detected from the connection (program ids and account ids are deterministic and +// can be identical across networks), so a multi-network registry needs AMM_NETWORK +// to disambiguate; otherwise nothing is applied. Selecting a network also exposes +// its AMM program id via activeAmmProgramId() so the backend can adopt it (no +// AMM_PROGRAM_BIN needed). // // refresh() bumps revision() and emits changed() whenever the snapshot updates, // so the backend re-publishes registryRevision and the UI re-fetches. @@ -42,16 +45,13 @@ class RegistryLoader : public QObject { QString source() const { return m_source; } // The network id the snapshot was filtered to (empty for local / none). QString activeNetwork() const { return m_activeNetwork; } + // The active network's declared AMM program id (empty for local / none / a + // network that declares none). The backend adopts it via setAmmProgramId so ops + // target this network without an AMM_PROGRAM_BIN. + QString activeAmmProgramId() const { return m_activeAmmProgramId; } - // The deployment the app is connected to (base58 program ids, from - // configAccount()): used to pick the matching network in a multi-network - // registry and to reject a registry whose active network contradicts it. - // Empty ⇒ selection falls back to AMM_NETWORK / a lone network. - void setConnectedProgramIds(const QString& ammProgramId, const QString& tokenProgramId); - - // Whether a local-file source (TOKENS_CONFIG / AMM_POOLS_CONFIG) is - // configured — it takes precedence over the remote registry. The backend uses - // this to skip the sequencer-touching configAccount read for local dev. + // Whether a local-file source (TOKENS_CONFIG / AMM_POOLS_CONFIG) is configured — + // it takes precedence over the remote registry (local-replaces-remote). static bool hasLocalSource(); public slots: @@ -67,7 +67,6 @@ public slots: // publish. Returns true when a snapshot was applied. bool applyRegistry(const QByteArray& body, const QString& source); QString selectActiveNetwork(const QJsonArray& networks) const; - bool deploymentOk(const QJsonObject& network) const; void publish(const QVariantList& tokens, const QVariantList& pools, const QString& source, const QString& network); @@ -83,9 +82,7 @@ public slots: int m_revision = 0; QString m_source = QStringLiteral("none"); QString m_activeNetwork; - - QString m_connectedAmm; - QString m_connectedToken; + QString m_activeAmmProgramId; // Registry `timestamp` reflected in the snapshot — lets a revalidation skip // re-applying an unchanged document. diff --git a/apps/amm/tests/testnet/setup-amm-testnet.sh b/apps/amm/tests/testnet/setup-amm-testnet.sh index 4cb28c75..01c84588 100755 --- a/apps/amm/tests/testnet/setup-amm-testnet.sh +++ b/apps/amm/tests/testnet/setup-amm-testnet.sh @@ -545,8 +545,9 @@ kv "wrote" "$POOLS_CONFIG_OUT" ############################################################################### sec "Write UI registry config -> $REGISTRY_CONFIG_OUT" # Same tokens/pools in the remote-registry shape: one "local" network, holding- -# agnostic tokens (the app resolves holdings from the wallet). programIds are left -# empty so the lone-network rule auto-selects "local"; the fresh timestamp forces +# agnostic tokens (the app resolves holdings from the wallet). programIds carry the +# freshly deployed ids so the lone-network rule auto-selects "local" and the app +# adopts its amm program id — no AMM_PROGRAM_BIN needed. The fresh timestamp forces # a re-fetch each run (ids change per deployment). { cat < $REGISTRY_CONFIG_OUT" "version": "0.1.0", "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", "networks": [ - { "id": "local", "name": "Local", "programIds": { "amm": "", "token": "" } } + { "id": "local", "name": "Local", "programIds": { "amm": "$AMM_PID", "token": "$TOKEN_PID" } } ], "tokens": [ { "network": "local", "symbol": "$TOKEN_A_SYMBOL", "name": "$TOKEN_A_NAME", "definitionId": "$TOKEN_A_DEF" }, @@ -603,9 +604,9 @@ log " ${DIM} CUSTOM_TOKEN_CONFIG=$REPO_ROOT/$CUSTOM_TOKEN_CONFIG_OUT \\${RST}" log " ${DIM} nix run .#amm-ui${RST}" log "" log "Or exercise the remote-registry path against the same sequencer — omit" -log "TOKENS_CONFIG/AMM_POOLS_CONFIG so the local files don't take precedence:" +log "TOKENS_CONFIG/AMM_POOLS_CONFIG so the local files don't take precedence. No" +log "AMM_PROGRAM_BIN: the registry carries the program ids and the app adopts them." log " ${DIM}LEE_WALLET_HOME_DIR=$TEST_WALLET_HOME \\${RST}" -log " ${DIM} AMM_PROGRAM_BIN=$REPO_ROOT/$AMM_BIN \\${RST}" log " ${DIM} AMM_REGISTRY_URL=file://$REPO_ROOT/$REGISTRY_CONFIG_OUT \\${RST}" log " ${DIM} CUSTOM_TOKEN_CONFIG=$REPO_ROOT/$CUSTOM_TOKEN_CONFIG_OUT \\${RST}" log " ${DIM} nix run .#amm-ui${RST}" From 66104881496f7517ed52674ff06c0b5c017b75c2 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:56:47 +0200 Subject: [PATCH 08/11] feat(apps/amm): persist a configurable registry URL setting Add a registryUrl PROP and saveRegistryUrl SLOT to the backend, backed by a global (per-user) QSettings value ("Logos"/"AmmUI"). RegistryLoader falls back to this configured URL when AMM_REGISTRY_URL is unset, so a packaged build needs no env var. saveRegistryUrl persists the value and reloads the registry; the disk cache is now keyed by the effective URL (env or configured) so it stays consistent. The config-field UI comes in the next commit. --- apps/amm/src/AmmUiBackend.cpp | 43 +++++++++++++++++++++++++++++++++ apps/amm/src/AmmUiBackend.h | 7 ++++++ apps/amm/src/AmmUiBackend.rep | 10 ++++++++ apps/amm/src/RegistryLoader.cpp | 9 +++++-- apps/amm/src/RegistryLoader.h | 6 +++++ 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 151a8e32..47a88aa6 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,15 @@ #include "logos_api.h" #include "logos_sdk.h" +namespace { +// Global (per-user) settings store, shared with WalletController's scope +// (QSettings("Logos", "AmmUI")). The registry URL is a per-user setting, not +// per-wallet, so it lives here rather than in the wallet home. +const char SETTINGS_ORG[] = "Logos"; +const char SETTINGS_APP[] = "AmmUI"; +const char REGISTRY_URL_KEY[] = "registryUrl"; +} + AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), @@ -40,6 +50,12 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) setRegistryRevision(m_registry->revision()); }); + // Seed the configured registry URL from the persisted global setting so the + // first refresh() and the config field both see it (AMM_REGISTRY_URL overrides). + const QString configuredUrl = loadRegistryUrlSetting(); + setRegistryUrl(configuredUrl); + m_registry->setConfiguredUrl(configuredUrl); + connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); // Publishes an initial "loading" context (walletStateReady is still false, @@ -255,6 +271,33 @@ void AmmUiBackend::refreshRegistry() m_registry->refresh(); } +void AmmUiBackend::saveRegistryUrl(QString url) +{ + // Persist the user's registry URL (global setting), publish it to the config + // field, and re-load from it. AMM_REGISTRY_URL still overrides on refresh(). + const QString trimmed = url.trimmed(); + storeRegistryUrlSetting(trimmed); + setRegistryUrl(trimmed); + m_registry->setConfiguredUrl(trimmed); + m_registry->refresh(); +} + +QString AmmUiBackend::loadRegistryUrlSetting() const +{ + return QSettings(QString::fromLatin1(SETTINGS_ORG), QString::fromLatin1(SETTINGS_APP)) + .value(QString::fromLatin1(REGISTRY_URL_KEY)) + .toString(); +} + +void AmmUiBackend::storeRegistryUrlSetting(const QString& url) const +{ + QSettings settings(QString::fromLatin1(SETTINGS_ORG), QString::fromLatin1(SETTINGS_APP)); + if (url.isEmpty()) + settings.remove(QString::fromLatin1(REGISTRY_URL_KEY)); + else + settings.setValue(QString::fromLatin1(REGISTRY_URL_KEY), url); +} + QVariantMap AmmUiBackend::createPoolQuote(QVariantMap request) { // Read-only create-pool preview — no wallet guard. The module prices the opening diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 1fb73621..b56fa136 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -104,9 +104,16 @@ public slots: QVariantMap addCustomToken(QString tokenId) override; // Re-loads the known-tokens / known-pools registry (bumps registryRevision). void refreshRegistry() override; + // Persists the registry URL (global setting) and reloads the registry from it. + void saveRegistryUrl(QString url) override; private: void syncWalletState(); + // The registry URL persisted as a global (per-user) QSettings value — the + // source RegistryLoader falls back to when AMM_REGISTRY_URL is unset. The + // getter returns "" when none is set. + QString loadRegistryUrlSetting() const; + void storeRegistryUrlSetting(const QString& url) const; // Persisted custom (user-pasted) token ids. Stored as a JSON array of id // strings at customTokenStorePath(); missing/unreadable ⇒ empty. The path is // CUSTOM_TOKEN_CONFIG if set, else a per-user app-data fallback. diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index e566432e..e1795251 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -22,6 +22,11 @@ class AmmUiBackend // re-fetch tokenList()/poolList()/resolveTokens(). Starts at 0; the backend // sets it once the initial snapshot has loaded (and again on each refresh). PROP(int registryRevision READONLY) + // The user-configured registry URL, persisted as a global (per-user) setting + // and edited in the wallet config UI. The env AMM_REGISTRY_URL overrides it when + // set (e2e / dev); empty means no registry is configured. Bound by the config + // field; saveRegistryUrl() writes it. + PROP(QString registryUrl READONLY) // Account management SLOT(QString createAccountPublic()) @@ -210,4 +215,9 @@ class AmmUiBackend // a remote registry). Bumps registryRevision when the snapshot updates so // QML re-fetches the lists. SLOT(void refreshRegistry()) + + // Persists the registry URL as a global (per-user) setting and re-loads the + // registry from it (unless AMM_REGISTRY_URL overrides). Updates the registryUrl + // PROP. An empty url clears the setting (no registry). Called by the config field. + SLOT(void saveRegistryUrl(QString url)) } diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp index 418ca306..5763d072 100644 --- a/apps/amm/src/RegistryLoader.cpp +++ b/apps/amm/src/RegistryLoader.cpp @@ -142,7 +142,10 @@ void RegistryLoader::refresh() return; } - const QString url = qEnvironmentVariable(REGISTRY_URL_ENV); + // AMM_REGISTRY_URL (e2e / dev) overrides the UI-configured URL. + QString url = qEnvironmentVariable(REGISTRY_URL_ENV); + if (url.isEmpty()) + url = m_configuredUrl; if (url.isEmpty()) { publish({}, {}, QStringLiteral("none"), {}); return; @@ -190,7 +193,9 @@ void RegistryLoader::startRemote(const QUrl& url) if (applyRegistry(body, QStringLiteral("remote"))) { m_stamp = stamp; - saveDiskCache(qEnvironmentVariable(REGISTRY_URL_ENV), stamp, body); + // Key the cache by the effective URL (env or UI-configured), matching + // what loadDiskCache() looks up. + saveDiskCache(reply->url().toString(), stamp, body); } }); } diff --git a/apps/amm/src/RegistryLoader.h b/apps/amm/src/RegistryLoader.h index 5b5c1337..b1034c85 100644 --- a/apps/amm/src/RegistryLoader.h +++ b/apps/amm/src/RegistryLoader.h @@ -54,6 +54,11 @@ class RegistryLoader : public QObject { // it takes precedence over the remote registry (local-replaces-remote). static bool hasLocalSource(); + // The registry URL to fetch when AMM_REGISTRY_URL is unset — the value the user + // configured in the wallet config UI (persisted by the backend). Empty ⇒ no + // remote source. Takes effect on the next refresh(). + void setConfiguredUrl(const QString& url) { m_configuredUrl = url; } + public slots: void refresh(); @@ -83,6 +88,7 @@ public slots: QString m_source = QStringLiteral("none"); QString m_activeNetwork; QString m_activeAmmProgramId; + QString m_configuredUrl; // UI-configured registry URL (env overrides) // Registry `timestamp` reflected in the snapshot — lets a revalidation skip // re-applying an unchanged document. From d3276f012a34ba132ce360291499c51dac0f2509 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:07:41 +0200 Subject: [PATCH 09/11] feat(apps/amm): add a Registry settings field to the wallet menu Add a "Registry settings" entry to the wallet menu that opens a page with a text field bound to the backend's registryUrl and saved via saveRegistryUrl. A packaged build can now set the registry URL in the UI instead of via AMM_REGISTRY_URL, which still overrides when set. The menu opens when a wallet is connected. --- apps/shared/wallet/qml/WalletControl.qml | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml index 28d32a13..f16d8e97 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -375,6 +375,13 @@ Item { } } } + + Button { + objectName: "walletRegistrySettingsButton" + Layout.fillWidth: true + text: qsTr("Registry settings") + onClicked: walletStack.push(registrySettings) + } } } @@ -433,6 +440,60 @@ Item { } } } + + Component { + id: registrySettings + + ColumnLayout { + spacing: 12 + + RowLayout { + Layout.fillWidth: true + + WalletIconButton { + iconSource: Qt.resolvedUrl("icons/back.svg") + accessibleName: qsTr("Back") + onClicked: walletStack.pop() + } + + Label { + Layout.fillWidth: true + text: qsTr("Registry") + color: "#f4f4f5" + font.bold: true + } + } + + Label { + Layout.fillWidth: true + text: qsTr("URL of the known-tokens / known-pools registry the app loads. Leave empty to load none.") + color: "#a1a1aa" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + TextField { + id: registryUrlField + objectName: "walletRegistryUrlField" + Layout.fillWidth: true + // Seeded once from the synced PROP when the page is created; the + // user edits freely and Save persists it. + text: root.wallet ? root.wallet.registryUrl : "" + placeholderText: qsTr("https://…/amm-registry.json") + } + + Button { + objectName: "walletRegistrySaveButton" + Layout.fillWidth: true + text: qsTr("Save") + onClicked: { + if (root.wallet) + root.wallet.saveRegistryUrl(registryUrlField.text) + walletStack.pop() + } + } + } + } } CreateWalletDialog { From 2beba3548db817f2b623f795a87ccabf06f8912a Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:28:08 +0200 Subject: [PATCH 10/11] refactor(apps/amm): drop the registry timestamp field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timestamp only fed a revalidation skip: a re-fetch reused the current snapshot when the document's timestamp matched the last applied one. Nothing polls in the background — a fetch happens only at startup, on manual refresh, or on a URL change — so the skip saved almost nothing and could wrongly skip a switch between two registries that happened to share a timestamp. Remove the field and always apply a fetched registry; the disk cache keeps its stale-while-revalidate behavior (keyed by URL, no stamp). Drop the timestamp from the setup script and the sample registry. --- apps/amm/src/RegistryLoader.cpp | 20 +++----------------- apps/amm/src/RegistryLoader.h | 5 +---- apps/amm/tests/testnet/setup-amm-testnet.sh | 4 +--- artifacts/amm-registry.json | 1 - 4 files changed, 5 insertions(+), 25 deletions(-) diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp index 5763d072..b726f50d 100644 --- a/apps/amm/src/RegistryLoader.cpp +++ b/apps/amm/src/RegistryLoader.cpp @@ -181,21 +181,10 @@ void RegistryLoader::startRemote(const QUrl& url) } const QByteArray body = reply->readAll(); - const QString stamp = QJsonDocument::fromJson(body) - .object() - .value(QStringLiteral("timestamp")) - .toVariant() - .toString(); - // Revalidation: an unchanged registry with a non-empty snapshot is - // already current. - if (!stamp.isEmpty() && stamp == m_stamp && !m_tokens.isEmpty()) - return; - if (applyRegistry(body, QStringLiteral("remote"))) { - m_stamp = stamp; // Key the cache by the effective URL (env or UI-configured), matching // what loadDiskCache() looks up. - saveDiskCache(reply->url().toString(), stamp, body); + saveDiskCache(reply->url().toString(), body); } }); } @@ -279,23 +268,20 @@ void RegistryLoader::loadDiskCache(const QString& url) if (obj.value(QStringLiteral("url")).toString() != url) return; - m_stamp = obj.value(QStringLiteral("stamp")).toVariant().toString(); - // Re-apply the cached registry against the current connection (the active + // Re-apply the cached registry against the current selection (the active // network may resolve differently than when it was written). const QByteArray body = QJsonDocument(obj.value(QStringLiteral("registry")).toObject()).toJson(QJsonDocument::Compact); applyRegistry(body, QStringLiteral("cache")); } -void RegistryLoader::saveDiskCache(const QString& url, const QString& stamp, - const QByteArray& body) const +void RegistryLoader::saveDiskCache(const QString& url, const QByteArray& body) const { const QString path = cachePath(); QDir().mkpath(QFileInfo(path).absolutePath()); QJsonObject obj; obj.insert(QStringLiteral("url"), url); - obj.insert(QStringLiteral("stamp"), stamp); obj.insert(QStringLiteral("registry"), QJsonDocument::fromJson(body).object()); QFile file(path); diff --git a/apps/amm/src/RegistryLoader.h b/apps/amm/src/RegistryLoader.h index b1034c85..02dfcd15 100644 --- a/apps/amm/src/RegistryLoader.h +++ b/apps/amm/src/RegistryLoader.h @@ -77,7 +77,7 @@ public slots: const QString& source, const QString& network); void loadDiskCache(const QString& url); - void saveDiskCache(const QString& url, const QString& stamp, const QByteArray& body) const; + void saveDiskCache(const QString& url, const QByteArray& body) const; static QString cachePath(); QNetworkAccessManager* nam(); @@ -90,9 +90,6 @@ public slots: QString m_activeAmmProgramId; QString m_configuredUrl; // UI-configured registry URL (env overrides) - // Registry `timestamp` reflected in the snapshot — lets a revalidation skip - // re-applying an unchanged document. - QString m_stamp; // Guards against overlapping refreshes: a reply from an older refresh is // dropped once a newer refresh has started. quint64 m_generation = 0; diff --git a/apps/amm/tests/testnet/setup-amm-testnet.sh b/apps/amm/tests/testnet/setup-amm-testnet.sh index 01c84588..0fd0cb33 100755 --- a/apps/amm/tests/testnet/setup-amm-testnet.sh +++ b/apps/amm/tests/testnet/setup-amm-testnet.sh @@ -547,14 +547,12 @@ sec "Write UI registry config -> $REGISTRY_CONFIG_OUT" # Same tokens/pools in the remote-registry shape: one "local" network, holding- # agnostic tokens (the app resolves holdings from the wallet). programIds carry the # freshly deployed ids so the lone-network rule auto-selects "local" and the app -# adopts its amm program id — no AMM_PROGRAM_BIN needed. The fresh timestamp forces -# a re-fetch each run (ids change per deployment). +# adopts its amm program id — no AMM_PROGRAM_BIN needed. { cat < Date: Mon, 31 Aug 2026 12:41:48 +0200 Subject: [PATCH 11/11] feat(apps/amm): add an app settings modal for the registry Add a cogwheel in the bottom-right corner that opens an app settings modal with a Registry section: the known-tokens / known-pools registry URL (a persisted global setting) and a network picker over the registry's networks, defaulting to the first. This is AMM-specific, so it lives in the app rather than the shared wallet UI. Bound to the backend's registryUrl / saveRegistryUrl / networks / activeNetwork / selectNetwork. --- apps/amm/qml/Main.qml | 39 ++++++ apps/amm/qml/chrome/SettingsModal.qml | 164 +++++++++++++++++++++++ apps/amm/src/AmmUiBackend.cpp | 9 ++ apps/amm/src/AmmUiBackend.h | 2 + apps/amm/src/AmmUiBackend.rep | 12 ++ apps/amm/src/RegistryLoader.cpp | 81 ++++++++--- apps/amm/src/RegistryLoader.h | 37 +++-- apps/shared/wallet/qml/WalletControl.qml | 61 --------- 8 files changed, 314 insertions(+), 91 deletions(-) create mode 100644 apps/amm/qml/chrome/SettingsModal.qml diff --git a/apps/amm/qml/Main.qml b/apps/amm/qml/Main.qml index f7d94132..b7f590a1 100644 --- a/apps/amm/qml/Main.qml +++ b/apps/amm/qml/Main.qml @@ -167,4 +167,43 @@ Item { visible: navbar.currentIndex === 2 && navbar.currentSubIndex === 1 } } + + // App settings: a cogwheel in the bottom-right corner opens the settings modal + // (registry URL + network picker). App-specific, so it lives here rather than + // in the shared wallet UI. + Rectangle { + id: settingsButton + objectName: "appSettingsButton" + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 20 + anchors.bottomMargin: 20 + z: 200 + width: 44 + height: 44 + radius: 22 + color: settingsMouse.pressed ? Theme.palette.borderSecondary + : Theme.palette.backgroundSecondary + border.color: Theme.palette.borderSecondary + border.width: 1 + + Text { + anchors.centerIn: parent + text: "⚙" // gear + font.pixelSize: 20 + color: Theme.palette.textSecondary + } + + MouseArea { + id: settingsMouse + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: settingsModal.open() + } + } + + SettingsModal { + id: settingsModal + backend: root.ready ? root.backend : null + } } diff --git a/apps/amm/qml/chrome/SettingsModal.qml b/apps/amm/qml/chrome/SettingsModal.qml new file mode 100644 index 00000000..ff19acf1 --- /dev/null +++ b/apps/amm/qml/chrome/SettingsModal.qml @@ -0,0 +1,164 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "../components/liquidity" + +// App settings modal, opened from the cogwheel in Main.qml. Currently a single +// "Registry" section: the known-tokens / known-pools registry URL and the network +// picker, bound to the AMM backend (registryUrl / saveRegistryUrl / networks / +// activeNetwork / selectNetwork). This is AMM-specific, so it lives in the app +// rather than the shared wallet UI. +Popup { + id: root + + property var backend: null + + AmmTheme { id: theme } + + parent: Overlay.overlay + modal: true + focus: true + width: parent && parent.width > 32 ? Math.max(0, Math.min(440, parent.width - 32)) : 300 + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) : 0 + padding: 20 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: { + registryUrlField.text = root.backend ? (root.backend.registryUrl || "") : "" + networkSelector.syncSelection() + } + + Overlay.modal: Rectangle { color: Qt.rgba(0, 0, 0, 0.4) } + + background: Rectangle { + radius: 16 + color: theme.colors.cardBg + border.color: theme.colors.border + border.width: 1 + } + + contentItem: ColumnLayout { + spacing: 14 + + RowLayout { + Layout.fillWidth: true + + Label { + Layout.fillWidth: true + text: qsTr("Settings") + color: theme.colors.textPrimary + font.bold: true + font.pixelSize: 17 + } + + Label { + text: "✕" // close + color: theme.colors.textSecondary + font.pixelSize: 16 + MouseArea { + anchors.fill: parent + anchors.margins: -8 + cursorShape: Qt.PointingHandCursor + onClicked: root.close() + } + } + } + + Label { + text: qsTr("Registry") + color: theme.colors.textPrimary + font.bold: true + } + + Label { + Layout.fillWidth: true + text: qsTr("URL of the known-tokens / known-pools registry the app loads. Leave empty to load none.") + color: theme.colors.textSecondary + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + TextField { + id: registryUrlField + objectName: "settingsRegistryUrlField" + Layout.fillWidth: true + text: root.backend ? (root.backend.registryUrl || "") : "" + placeholderText: qsTr("https://…/amm-registry.json") + color: theme.colors.textPrimary + background: Rectangle { + radius: 8 + color: theme.colors.inputBg + border.color: theme.colors.border + border.width: 1 + } + } + + Button { + id: saveButton + objectName: "settingsRegistrySaveButton" + Layout.fillWidth: true + text: qsTr("Save") + onClicked: { + if (root.backend) + root.backend.saveRegistryUrl(registryUrlField.text) + } + contentItem: Text { + text: saveButton.text + color: "#FFFFFF" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 8 + implicitHeight: 36 + color: saveButton.pressed ? theme.colors.ctaPressedBg + : saveButton.hovered ? theme.colors.ctaHoverBg + : theme.colors.ctaBg + } + } + + Label { + Layout.fillWidth: true + visible: networkSelector.count > 0 + text: qsTr("Network") + color: theme.colors.textSecondary + font.pixelSize: 11 + } + + ComboBox { + id: networkSelector + objectName: "settingsNetworkSelector" + Layout.fillWidth: true + visible: count > 0 + textRole: "name" + valueRole: "id" + model: root.backend ? root.backend.networks : [] + + // Select the active network (which defaults to the first), falling back + // to the first item. Imperative — the model syncs from the backend after + // this is created, so a currentIndex binding would compute -1 before the + // model arrives and never re-run. + function syncSelection() { + if (!root.backend || count === 0) + return + const i = indexOfValue(root.backend.activeNetwork) + currentIndex = i >= 0 ? i : 0 + } + Component.onCompleted: syncSelection() + onCountChanged: syncSelection() + Connections { + target: root.backend + function onActiveNetworkChanged() { networkSelector.syncSelection() } + } + + onActivated: { + if (root.backend) + root.backend.selectNetwork(currentValue) + } + } + } +} diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 47a88aa6..df7d0518 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -47,6 +47,8 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) connect(m_registry.get(), &RegistryLoader::changed, this, [this]() { m_logos->amm_module.setAmmProgramId(QVariantMap{ {QStringLiteral("ammProgramId"), m_registry->activeAmmProgramId()}}); + setNetworks(m_registry->networks()); + setActiveNetwork(m_registry->activeNetwork()); setRegistryRevision(m_registry->revision()); }); @@ -271,6 +273,13 @@ void AmmUiBackend::refreshRegistry() m_registry->refresh(); } +void AmmUiBackend::selectNetwork(QString id) +{ + // The loader re-filters the loaded registry and emits changed(), which adopts + // the new program id, updates activeNetwork, and bumps registryRevision. + m_registry->selectNetwork(id); +} + void AmmUiBackend::saveRegistryUrl(QString url) { // Persist the user's registry URL (global setting), publish it to the config diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index b56fa136..bc5c7b73 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -106,6 +106,8 @@ public slots: void refreshRegistry() override; // Persists the registry URL (global setting) and reloads the registry from it. void saveRegistryUrl(QString url) override; + // Switches the active network (re-filters the loaded registry, no re-fetch). + void selectNetwork(QString id) override; private: void syncWalletState(); diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index e1795251..5bdc824f 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -27,6 +27,13 @@ class AmmUiBackend // set (e2e / dev); empty means no registry is configured. Bound by the config // field; saveRegistryUrl() writes it. PROP(QString registryUrl READONLY) + // The id of the network the registry data is currently filtered to (see + // networks/selectNetwork()). Empty for local / none sources. Lets the network + // picker mark the current selection; updates on each registry refresh / pick. + PROP(QString activeNetwork READONLY) + // The registry's declared networks as [{ id, name }] for the network picker + // (empty for local / none sources). Auto-syncs on each registry refresh / pick. + PROP(QVariantList networks READONLY) // Account management SLOT(QString createAccountPublic()) @@ -220,4 +227,9 @@ class AmmUiBackend // registry from it (unless AMM_REGISTRY_URL overrides). Updates the registryUrl // PROP. An empty url clears the setting (no registry). Called by the config field. SLOT(void saveRegistryUrl(QString url)) + + // Switches the active network to `id` (from the networks list): re-filters the + // loaded registry's tokens/pools and re-adopts its program id with no re-fetch, + // then bumps registryRevision and updates activeNetwork. + SLOT(void selectNetwork(QString id)) } diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp index b726f50d..90e97691 100644 --- a/apps/amm/src/RegistryLoader.cpp +++ b/apps/amm/src/RegistryLoader.cpp @@ -147,6 +147,7 @@ void RegistryLoader::refresh() if (url.isEmpty()) url = m_configuredUrl; if (url.isEmpty()) { + m_registryObj = {}; // no registry ⇒ no networks to pick publish({}, {}, QStringLiteral("none"), {}); return; } @@ -161,6 +162,7 @@ void RegistryLoader::refresh() void RegistryLoader::loadLocal() { + m_registryObj = {}; // local files carry no networks to pick // Local files are bare arrays with no network tag — no filtering. publish(parseTokens(jsonArrayFromBytes(readConfigFileBytes(TOKENS_CONFIG_ENV)), {}), parsePools(jsonArrayFromBytes(readConfigFileBytes(POOLS_CONFIG_ENV)), {}), @@ -196,13 +198,19 @@ bool RegistryLoader::applyRegistry(const QByteArray& body, const QString& source qWarning() << "AMM registry: document is not a JSON object"; return false; } - const QJsonObject registry = doc.object(); - const QJsonArray networks = registry.value(QStringLiteral("networks")).toArray(); + m_registryObj = doc.object(); + m_lastSource = source; + return applySelection(); +} +bool RegistryLoader::applySelection() +{ + const QJsonArray networks = m_registryObj.value(QStringLiteral("networks")).toArray(); const QString activeId = selectActiveNetwork(networks); if (activeId.isEmpty()) { - qWarning() << "AMM registry: cannot determine the active network" - " (set AMM_NETWORK for a multi-network registry); not applied"; + qWarning() << "AMM registry: no networks declared; nothing applied"; + m_activeAmmProgramId.clear(); + publish({}, {}, m_lastSource, {}); return false; } @@ -220,31 +228,64 @@ bool RegistryLoader::applyRegistry(const QByteArray& body, const QString& source } } - publish(parseTokens(registry.value(QStringLiteral("tokens")).toArray(), activeId), - parsePools(registry.value(QStringLiteral("pools")).toArray(), activeId), - source, activeId); + publish(parseTokens(m_registryObj.value(QStringLiteral("tokens")).toArray(), activeId), + parsePools(m_registryObj.value(QStringLiteral("pools")).toArray(), activeId), + m_lastSource, activeId); return true; } +void RegistryLoader::selectNetwork(const QString& id) +{ + if (id == m_selectedNetwork) + return; + m_selectedNetwork = id; + // Re-filter the already-loaded registry to the new pick (no re-fetch). If none + // is loaded yet, the pick is remembered and applied when one loads. + if (!m_registryObj.isEmpty()) + applySelection(); +} + +QVariantList RegistryLoader::networks() const +{ + QVariantList out; + const QJsonArray nets = m_registryObj.value(QStringLiteral("networks")).toArray(); + for (const QJsonValue& entry : nets) { + const QJsonObject net = entry.toObject(); + const QString id = net.value(QStringLiteral("id")).toString(); + if (id.isEmpty()) + continue; + out.append(QVariantMap{ + {QStringLiteral("id"), id}, + {QStringLiteral("name"), net.value(QStringLiteral("name")).toString()}, + }); + } + return out; +} + QString RegistryLoader::selectActiveNetwork(const QJsonArray& networks) const { - // Explicit override wins if it names a declared network. - const QString forced = qEnvironmentVariable(NETWORK_ENV); - if (!forced.isEmpty()) { + if (networks.isEmpty()) + return {}; + + const auto declares = [&networks](const QString& id) { for (const QJsonValue& entry : networks) { - if (entry.toObject().value(QStringLiteral("id")).toString() == forced) - return forced; + if (entry.toObject().value(QStringLiteral("id")).toString() == id) + return true; } - return {}; - } + return false; + }; - // A single declared network is unambiguous. Multiple networks can't be told - // apart from the connection (program ids and account ids are deterministic and - // may be identical across networks), so AMM_NETWORK is required to pick one. - if (networks.size() == 1) - return networks.at(0).toObject().value(QStringLiteral("id")).toString(); + // The user's explicit pick, if it's still a declared network. + if (!m_selectedNetwork.isEmpty() && declares(m_selectedNetwork)) + return m_selectedNetwork; + + // AMM_NETWORK sets the initial default (e2e / dev), if it names one. + const QString forced = qEnvironmentVariable(NETWORK_ENV); + if (!forced.isEmpty() && declares(forced)) + return forced; - return {}; + // Otherwise default to the first declared network. + return networks.at(0).toObject().value(QStringLiteral("id")).toString(); } void RegistryLoader::publish(const QVariantList& tokens, const QVariantList& pools, diff --git a/apps/amm/src/RegistryLoader.h b/apps/amm/src/RegistryLoader.h index 02dfcd15..0b105a19 100644 --- a/apps/amm/src/RegistryLoader.h +++ b/apps/amm/src/RegistryLoader.h @@ -6,9 +6,10 @@ #include #include +#include + class QNetworkAccessManager; class QJsonArray; -class QJsonObject; // Loads the AMM app's "known tokens" and "known pools" and serves them as an // in-memory snapshot the backend's QtRO slots read synchronously. @@ -22,13 +23,13 @@ class QJsonObject; // (QNetworkAccessManager) with an on-disk cache served meanwhile // (stale-while-revalidate). Entries are filtered to the active network. // -// Active network = AMM_NETWORK if it names a declared network, else the lone -// network when the registry declares exactly one. Network identity can't be -// detected from the connection (program ids and account ids are deterministic and -// can be identical across networks), so a multi-network registry needs AMM_NETWORK -// to disambiguate; otherwise nothing is applied. Selecting a network also exposes -// its AMM program id via activeAmmProgramId() so the backend can adopt it (no -// AMM_PROGRAM_BIN needed). +// Active network = the user's selection (selectNetwork), else AMM_NETWORK if it +// names a declared network, else the first declared network. Network identity can't +// be detected from the connection (program ids and account ids are deterministic and +// can be identical across networks), so the user picks; networks() lists them for +// the picker. selectNetwork() re-filters the last-loaded registry with no re-fetch. +// The active network's AMM program id is exposed via activeAmmProgramId() so the +// backend can adopt it (no AMM_PROGRAM_BIN needed). // // refresh() bumps revision() and emits changed() whenever the snapshot updates, // so the backend re-publishes registryRevision and the UI re-fetches. @@ -45,6 +46,9 @@ class RegistryLoader : public QObject { QString source() const { return m_source; } // The network id the snapshot was filtered to (empty for local / none). QString activeNetwork() const { return m_activeNetwork; } + // The registry's declared networks as [{ id, name }] for the picker (empty for + // local / none). The active one is activeNetwork(). + QVariantList networks() const; // The active network's declared AMM program id (empty for local / none / a // network that declares none). The backend adopts it via setAmmProgramId so ops // target this network without an AMM_PROGRAM_BIN. @@ -61,6 +65,10 @@ class RegistryLoader : public QObject { public slots: void refresh(); + // Pick a network by id (from networks()). Re-filters the last-loaded registry + // and re-adopts its program id with no re-fetch; ignored if no registry is + // loaded yet (the pick is remembered and applied when one loads). + void selectNetwork(const QString& id); signals: void changed(); @@ -68,9 +76,12 @@ public slots: private: void loadLocal(); void startRemote(const QUrl& url); - // Parse the registry body, select + guard the active network, filter, and - // publish. Returns true when a snapshot was applied. + // Parse the registry body into m_registryObj, then applySelection(). Returns + // true when a snapshot was applied. bool applyRegistry(const QByteArray& body, const QString& source); + // Select the active network from the stored registry, filter its tokens/pools, + // adopt its program id, and publish. Returns true when a network was applied. + bool applySelection(); QString selectActiveNetwork(const QJsonArray& networks) const; void publish(const QVariantList& tokens, const QVariantList& pools, @@ -90,6 +101,12 @@ public slots: QString m_activeAmmProgramId; QString m_configuredUrl; // UI-configured registry URL (env overrides) + // The last-loaded registry document, kept so selectNetwork() can re-filter to a + // different network without re-fetching. Empty for local / none sources. + QJsonObject m_registryObj; + QString m_lastSource; // source label of m_registryObj ("remote"/"cache") + QString m_selectedNetwork; // the user's picked network id (empty ⇒ default) + // Guards against overlapping refreshes: a reply from an older refresh is // dropped once a newer refresh has started. quint64 m_generation = 0; diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml index f16d8e97..28d32a13 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -375,13 +375,6 @@ Item { } } } - - Button { - objectName: "walletRegistrySettingsButton" - Layout.fillWidth: true - text: qsTr("Registry settings") - onClicked: walletStack.push(registrySettings) - } } } @@ -440,60 +433,6 @@ Item { } } } - - Component { - id: registrySettings - - ColumnLayout { - spacing: 12 - - RowLayout { - Layout.fillWidth: true - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/back.svg") - accessibleName: qsTr("Back") - onClicked: walletStack.pop() - } - - Label { - Layout.fillWidth: true - text: qsTr("Registry") - color: "#f4f4f5" - font.bold: true - } - } - - Label { - Layout.fillWidth: true - text: qsTr("URL of the known-tokens / known-pools registry the app loads. Leave empty to load none.") - color: "#a1a1aa" - font.pixelSize: 11 - wrapMode: Text.WordWrap - } - - TextField { - id: registryUrlField - objectName: "walletRegistryUrlField" - Layout.fillWidth: true - // Seeded once from the synced PROP when the page is created; the - // user edits freely and Save persists it. - text: root.wallet ? root.wallet.registryUrl : "" - placeholderText: qsTr("https://…/amm-registry.json") - } - - Button { - objectName: "walletRegistrySaveButton" - Layout.fillWidth: true - text: qsTr("Save") - onClicked: { - if (root.wallet) - root.wallet.saveRegistryUrl(registryUrlField.text) - walletStack.pop() - } - } - } - } } CreateWalletDialog {