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/CMakeLists.txt b/apps/amm/CMakeLists.txt index 5ad917a8..57a2b394 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -36,10 +36,13 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp + src/RegistryLoader.h + src/RegistryLoader.cpp FIND_PACKAGES Qt6Gui LINK_LIBRARIES Qt6::Gui + Qt6::Network LINK_TARGETS logos_wallet_access ) 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/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/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 5ec64c86..df7d0518 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -10,127 +10,54 @@ #include #include #include +#include #include #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 readPoolsConfig() - { - 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()); - 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 readTokensConfig() - { - 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()); - 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; - } +// 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)), 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); + // 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()}}); + setNetworks(m_registry->networks()); + setActiveNetwork(m_registry->activeNetwork()); + 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, @@ -140,6 +67,10 @@ 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). The changed() handler adopts the selected + // network's program id on the module. + m_registry->refresh(); }); } @@ -330,8 +261,50 @@ 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(); +} + +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 + // 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) @@ -367,8 +340,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() @@ -388,7 +361,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..bc5c7b73 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,9 +102,20 @@ 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; + // 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(); + // 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. @@ -120,6 +132,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..5bdc824f 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -18,6 +18,22 @@ 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) + // 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) + // 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()) @@ -201,4 +217,19 @@ 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()) + + // 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)) + + // 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 new file mode 100644 index 00000000..90e97691 --- /dev/null +++ b/apps/amm/src/RegistryLoader.cpp @@ -0,0 +1,345 @@ +#include "RegistryLoader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + // 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 single multi-network registry document. + constexpr char REGISTRY_URL_ENV[] = "AMM_REGISTRY_URL"; + // 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 is + // required. `decimals` is optional (the app doesn't use it yet); absent ⇒ 0. + 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 QString definitionId = obj.value(QStringLiteral("definitionId")).toString(); + const QJsonValue decimals = obj.value(QStringLiteral("decimals")); + if (definitionId.isEmpty()) + 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"), 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(); + 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; + } + + 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) + : QObject(parent) +{ +} + +bool RegistryLoader::hasLocalSource() +{ + return !qEnvironmentVariableIsEmpty(TOKENS_CONFIG_ENV) + || !qEnvironmentVariableIsEmpty(POOLS_CONFIG_ENV); +} + +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(); + return; + } + + // 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()) { + m_registryObj = {}; // no registry ⇒ no networks to pick + 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() +{ + 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)), {}), + QStringLiteral("local"), {}); +} + +void RegistryLoader::startRemote(const QUrl& url) +{ + const quint64 generation = m_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: fetch failed:" << reply->errorString(); + return; // keep serving whatever we have (cache / previous) + } + + const QByteArray body = reply->readAll(); + if (applyRegistry(body, QStringLiteral("remote"))) { + // Key the cache by the effective URL (env or UI-configured), matching + // what loadDiskCache() looks up. + saveDiskCache(reply->url().toString(), body); + } + }); +} + +bool RegistryLoader::applyRegistry(const QByteArray& body, const QString& source) +{ + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (!doc.isObject()) { + qWarning() << "AMM registry: document is not a JSON object"; + return false; + } + 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: no networks declared; nothing applied"; + m_activeAmmProgramId.clear(); + publish({}, {}, m_lastSource, {}); + return false; + } + + // 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) { + const QJsonObject net = entry.toObject(); + if (net.value(QStringLiteral("id")).toString() == activeId) { + m_activeAmmProgramId = net.value(QStringLiteral("programIds")) + .toObject() + .value(QStringLiteral("amm")) + .toString(); + break; + } + } + + 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 +{ + if (networks.isEmpty()) + return {}; + + const auto declares = [&networks](const QString& id) { + for (const QJsonValue& entry : networks) { + if (entry.toObject().value(QStringLiteral("id")).toString() == id) + return true; + } + return false; + }; + + // 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; + + // 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, + const QString& source, const QString& network) +{ + m_tokens = tokens; + m_pools = pools; + m_source = source; + m_activeNetwork = network; + ++m_revision; + emit changed(); +} + +void RegistryLoader::loadDiskCache(const QString& url) +{ + QFile file(cachePath()); + if (!file.open(QIODevice::ReadOnly)) + return; + 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; + + // 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 QByteArray& body) const +{ + const QString path = cachePath(); + QDir().mkpath(QFileInfo(path).absolutePath()); + + QJsonObject obj; + obj.insert(QStringLiteral("url"), url); + obj.insert(QStringLiteral("registry"), QJsonDocument::fromJson(body).object()); + + 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 new file mode 100644 index 00000000..0b105a19 --- /dev/null +++ b/apps/amm/src/RegistryLoader.h @@ -0,0 +1,117 @@ +#ifndef AMM_UI_REGISTRY_LOADER_H +#define AMM_UI_REGISTRY_LOADER_H + +#include +#include +#include +#include + +#include + +class QNetworkAccessManager; +class QJsonArray; + +// 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 (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 = 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. +class RegistryLoader : public QObject { + Q_OBJECT + +public: + explicit RegistryLoader(QObject* parent = nullptr); + + QVariantList tokens() const { return m_tokens; } + QVariantList pools() const { return m_pools; } + 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 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. + QString activeAmmProgramId() const { return m_activeAmmProgramId; } + + // 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(); + + // 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(); + // 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(); + +private: + void loadLocal(); + void startRemote(const QUrl& url); + // 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, + const QString& source, const QString& network); + + void loadDiskCache(const QString& url); + void saveDiskCache(const QString& url, const QByteArray& body) const; + static QString cachePath(); + + QNetworkAccessManager* nam(); + + QVariantList m_tokens; + QVariantList m_pools; + int m_revision = 0; + QString m_source = QStringLiteral("none"); + QString m_activeNetwork; + 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; + + QNetworkAccessManager* m_nam = nullptr; // lazily created +}; + +#endif // AMM_UI_REGISTRY_LOADER_H diff --git a/apps/amm/tests/testnet/setup-amm-testnet.sh b/apps/amm/tests/testnet/setup-amm-testnet.sh index 031e3318..62ea49c9 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 (see docs/amm-registry-plan.md), 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,44 @@ 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 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. +{ + cat < "$REGISTRY_CONFIG_OUT" +kv "wrote" "$REGISTRY_CONFIG_OUT" + ############################################################################### # 12. Initialize the isolated custom-token store (empty) ############################################################################### @@ -557,6 +601,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. 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_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 new file mode 100644 index 00000000..867ef47f --- /dev/null +++ b/artifacts/amm-registry.json @@ -0,0 +1,7 @@ +{ + "name": "Logos AMM registry", + "version": "0.1.0", + "networks": [], + "tokens": [], + "pools": [] +} diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index a16bb654..b2b02a3f 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,12 @@ std::string AmmModuleImpl::ammProgramId() { return jStr(r.value, "programId"); } +LogosMap AmmModuleImpl::setAmmProgramId(const LogosMap& request) { + // Adopt the caller's chosen id (normalized to hex; empty reverts to the bin). + m_activeProgramId = normalizeAccountId(jStr(request, "ammProgramId")); + return LogosMap{{"status", "ok"}}; +} + 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; };