From 13732cd7e2e031708ecbc17111492a2767e537a6 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:23:28 +0200 Subject: [PATCH] feat(amm): source liquidity tokens app-side + add custom tokens by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the liquidity token selector off the module's stateful newPositionContext onto a lean, app-owned surface, and let users add unlisted tokens by id. FFI: new stateless `resolve_tokens` op — the app passes an explicit id set and gets uniform selector rows `{ definitionId (base58), name, totalSupply, holdingId, balance }`, held tokens first, unresolvable/non-fungible ids omitted. Reuses the per-token definition/holding logic from `context`, without the network/status envelope. Unit-tested. Module: `resolveTokens(request, wallet_open)` reads the definitions + wallet and calls the op (ids wrapped in a map — the universal-module glue only marshals map/scalar inputs, not bare lists). Backend: the app owns the id set — configured tokens (TOKENS_CONFIG) plus the user's persisted custom ids. Held-but-unlisted tokens are NOT auto-listed (the list mirrors the swap side); a token you hold still shows its balance once listed. `addCustomToken` validates a pasted id by resolving its on-chain definition, then persists it to CUSTOM_TOKEN_CONFIG (defaulting to the per-user app-data store, with a HOME fallback so persistence never silently no-ops on an empty path). QML: NewPositionForm/LiquidityPage take tokens/walletReady/loadingTokens as inputs and drive selection + custom-token resolution through the backend; dropped all newPositionContext reads and the selectable/status/code row fields. Tests: custom-token.mjs creates token D on-chain (left out of the token config) and verifies pasting its id resolves, selects, and persists it across a reload. The setup script mints token D and initializes/prints the isolated CUSTOM_TOKEN_CONFIG store --- apps/amm/.gitignore | 4 + .../components/liquidity/NewPositionForm.qml | 154 ++++----------- apps/amm/qml/pages/LiquidityPage.qml | 84 ++++++-- apps/amm/src/AmmUiBackend.cpp | 120 +++++++++++ apps/amm/src/AmmUiBackend.h | 11 ++ apps/amm/src/AmmUiBackend.rep | 14 ++ apps/amm/tests/README.md | 10 + apps/amm/tests/custom-token.mjs | 186 ++++++++++++++++++ apps/amm/tests/testnet/setup-amm-testnet.sh | 63 +++++- modules/amm/ffi/include/amm_ffi.h | 2 + modules/amm/ffi/src/api/context.rs | 70 ++++++- modules/amm/ffi/src/api/mod.rs | 7 +- modules/amm/ffi/src/api/request.rs | 17 ++ modules/amm/ffi/src/api/tests.rs | 55 +++++- modules/amm/ffi/src/ffi.rs | 7 +- modules/amm/ffi/src/lib.rs | 13 +- modules/amm/src/amm_module_impl.cpp | 56 ++++++ modules/amm/src/amm_module_impl.h | 14 ++ 18 files changed, 737 insertions(+), 150 deletions(-) create mode 100644 apps/amm/tests/custom-token.mjs diff --git a/apps/amm/.gitignore b/apps/amm/.gitignore index d58d972..9d4018c 100644 --- a/apps/amm/.gitignore +++ b/apps/amm/.gitignore @@ -20,3 +20,7 @@ tests/testnet/amm-tokens.json # Isolated known-pools config written by tests/testnet/setup-amm-testnet.sh tests/testnet/amm-pools.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/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index c7d3be0..efcd97b 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -19,8 +19,10 @@ AmmActionCard { id: fallbackTheme } - property var newPositionContext: ({}) property var flowState: ({}) + // True while the app is (re)loading the token selector rows (backend.resolveTokens()); + // gates the selectors/spinner like the old context load did. + property bool loadingTokens: false // Wallet token holdings (backend.tokenHoldings()) for the create-pool account // selectors, narrowed per side by the selected token's base58 definitionId. The // chosen holdings feed the createPool call via submissionSnapshot(). @@ -51,7 +53,7 @@ AmmActionCard { property bool showRefreshAction: true readonly property var quotePayload: root.flowState.quote || ({}) - readonly property bool contextLoading: root.flowState.contextLoading === true + readonly property bool contextLoading: root.loadingTokens readonly property bool quoteLoading: root.flowState.quoteLoading === true readonly property bool submitting: root.flowState.submitting === true readonly property bool quoteStale: root.flowState.quoteStale === true @@ -61,12 +63,23 @@ AmmActionCard { readonly property var emptyToken: ({ "definitionId": "", "name": "", - "totalSupplyRaw": "0", - "balanceRaw": "0", - "selectable": false + "totalSupply": "0", + "balance": "0" }) - readonly property var tokens: root.newPositionContext && root.newPositionContext.tokens - ? root.newPositionContext.tokens : [] + // The liquidity token selector rows, injected from the app (backend.resolveTokens()): + // the union of configured tokens and persisted-custom tokens. Every row is + // { definitionId (base58), name, totalSupply, holdingId, balance } and already valid + // (unresolvable ids are omitted upstream), so every listed token is selectable. + property var tokens: [] + // The ids currently offered by the selector — a readable projection of `tokens` (every + // listed token is selectable). Exposed as a property so it can be observed directly. + readonly property var selectableTokenIdList: root.selectableTokenIds() + // Same set as a comma-joined string — a form that serializes reliably over the QML + // inspector (var arrays may not), used by the custom-token test. + readonly property string selectableTokenIdsCsv: root.selectableTokenIds().join(",") + // Whether the wallet session is ready (from the flow); gates funding/selection like the + // old context "ready"/"no_wallet" status did, minus the network envelope. + property bool walletReady: false // Supported fee tiers as raw bps, injected from backend.feeTiers() (amm_core's // SUPPORTED_FEE_TIERS). The selector's delegate wants { feeBps } rows, so wrap // each int; labels are derived locally via feeLabel(). @@ -149,13 +162,10 @@ AmmActionCard { implicitWidth: 480 Component.onCompleted: Qt.callLater(root.reconcileSelection) - onNewPositionContextChanged: Qt.callLater(root.applyContextChange) - function applyContextChange() { - if (root.resolvingToken) - root.finishTokenResolution() - else - root.reconcileSelection() - } + // Re-reconcile the current selection whenever the app's token list changes (e.g. after a + // wallet toggle or a custom token is added). Resolution completion is driven externally + // (LiquidityPage calls finishTokenResolution once addCustomToken returns). + onTokensChanged: Qt.callLater(root.reconcileSelection) onQuotePayloadChanged: { if (root.quoteStale) return @@ -242,26 +252,6 @@ AmmActionCard { } } - Rectangle { - Layout.fillWidth: true - implicitHeight: networkMessage.implicitHeight + 20 - radius: 6 - color: root.theme.colors.panelBg - border.color: root.theme.colors.error - visible: root.contextBlocksForm() - - Text { - id: networkMessage - anchors.fill: parent - anchors.margins: 10 - text: root.contextErrorText() - color: root.theme.colors.textPrimary - font.pixelSize: 12 - wrapMode: Text.Wrap - verticalAlignment: Text.AlignVCenter - } - } - ColumnLayout { Layout.fillWidth: true spacing: 0 @@ -578,26 +568,6 @@ AmmActionCard { } } - Rectangle { - Layout.fillWidth: true - implicitHeight: warningTextItem.implicitHeight + 20 - radius: 6 - color: root.theme.colors.panelBg - border.color: root.theme.colors.ctaBg - visible: root.warningText().length > 0 - - Text { - id: warningTextItem - anchors.fill: parent - anchors.margins: 10 - text: root.warningText() - color: root.theme.colors.textPrimary - font.pixelSize: 12 - wrapMode: Text.Wrap - verticalAlignment: Text.AlignVCenter - } - } - SubmittedTransaction { Layout.fillWidth: true title: qsTr("Position submitted") @@ -712,9 +682,11 @@ AmmActionCard { } function selectableTokenIds() { + // Every injected row is already a valid, selectable token (resolveTokens omits + // anything unresolvable), so all listed ids are selectable. var result = [] for (var i = 0; i < root.tokens.length; ++i) { - if (root.tokens[i].selectable === true) + if (root.tokens[i].definitionId) result.push(root.tokens[i].definitionId) } return result @@ -735,55 +707,33 @@ AmmActionCard { return } + // Already in the app's token list → select directly (every listed token is valid). var current = root.tokenById(tokenId) if (current.definitionId === tokenId) { - if (current.selectable === true) - root.selectToken(side, tokenId) - else { - root.tokenResolutionError = root.issueText(current.code || current.status) - root.tokenResolutionErrorSide = side - } + root.selectToken(side, tokenId) return } + // A custom/pasted id: ask the app to validate + persist it (backend.addCustomToken), + // which calls finishTokenResolution(token) on success or failTokenResolution(code) on + // an unresolvable / non-fungible id. root.resolvingTokenId = tokenId root.resolvingTokenSide = side root.tokenResolveRequested(tokenId) } - function finishTokenResolution(finalResponse) { + function finishTokenResolution(token) { if (!root.resolvingToken) return - var token = root.tokenById(root.resolvingTokenId) if (!token || !token.definitionId) { - if (finalResponse === true) { - var currentStatus = String(root.newPositionContext.status || "") - var code = currentStatus !== "ready" && currentStatus !== "no_wallet" - && currentStatus !== "loading" - ? root.newPositionContext.code || currentStatus - : "token_definition_unreadable" - root.failTokenResolution(code) - } - return - } - var status = String(root.newPositionContext.status || "") - if (status === "loading") - return - if (status !== "ready" && status !== "no_wallet") { - root.failTokenResolution(root.newPositionContext.code || status) + root.failTokenResolution("token_definition_unreadable") return } var side = root.resolvingTokenSide root.resolvingTokenId = "" root.resolvingTokenSide = "" - if (token.selectable !== true) { - root.tokenResolutionError = root.issueText(token.code || token.status) - root.tokenResolutionErrorSide = side - return - } - root.tokenResolutionMessage = qsTr("%1 - raw supply %2") .arg(token.name || root.shortId(token.definitionId)) - .arg(AmountMath.formatRaw(token.totalSupplyRaw || "0", 0)) + .arg(AmountMath.formatRaw(token.totalSupply || "0", 0)) root.selectToken(side, token.definitionId) } @@ -798,9 +748,6 @@ AmmActionCard { } function reconcileSelection() { - var status = String(root.newPositionContext.status || "") - if (status !== "ready" && status !== "no_wallet") - return var previousA = root.selectedTokenAId var previousB = root.selectedTokenBId var selectable = root.selectableTokenIds() @@ -1070,7 +1017,7 @@ AmmActionCard { } function probeRaw(token, decimals) { - var balance = String(token.balanceRaw || "0") + var balance = String(token.balance || "0") var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000") if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0) return balance @@ -1258,8 +1205,8 @@ AmmActionCard { var reserveB = root.poolReserve("B") if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0") return - var balanceA = String(root.tokenA.balanceRaw || "0") - var balanceB = String(root.tokenB.balanceRaw || "0") + var balanceA = String(root.tokenA.balance || "0") + var balanceB = String(root.tokenB.balance || "0") var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB) var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA) @@ -1429,12 +1376,6 @@ AmmActionCard { return "" } - function warningText() { - // The lean quotes carry no warnings; only the token-sourcing context may. - var warnings = root.newPositionContext.warnings || [] - return warnings.length > 0 ? root.issueText(warnings[0].code) : "" - } - function submissionSnapshot() { var built = root.buildQuoteRequest() return { @@ -1466,7 +1407,7 @@ AmmActionCard { } function balanceText(token, decimals) { - return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals) + return AmountMath.formatRaw(String(token.balance || "0"), decimals) } function tokenBalanceDetail(token) { @@ -1514,20 +1455,7 @@ AmmActionCard { } function contextStatusText() { - var network = String(root.newPositionContext.networkId || "") - if (root.newPositionContext.status === "no_wallet") - return qsTr("%1 · simulation only").arg(network || qsTr("Wallet disconnected")) - if (root.newPositionContext.status === "ready") - return qsTr("%1 · wallet ready").arg(network) - return network.length > 0 ? network : qsTr("Loading network") - } - - function contextBlocksForm() { - var status = String(root.newPositionContext.status || "") - return status !== "" && status !== "ready" && status !== "no_wallet" && status !== "loading" - } - - function contextErrorText() { - return root.issueText(root.newPositionContext.code || root.newPositionContext.status) + return root.walletReady ? qsTr("Wallet ready") + : qsTr("Wallet disconnected · simulation only") } } diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index c84fb7b..994f8f7 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -13,6 +13,8 @@ import "../state" Item { id: root + objectName: "liquidityPage" + property var backend: null property var runtime: null readonly property NewPositionFlow flow: newPositionFlow @@ -26,6 +28,13 @@ Item { // becomes available. property var feeTiers: [] + // The liquidity token selector rows (backend.resolveTokens()): the app-owned union of + // configured tokens and persisted-custom tokens. Refetched when the wallet opens/closes + // (holdingId/balance change) and after a custom token is added. + property var resolvedTokens: [] + property bool tokensLoading: false + property int tokensGeneration: 0 + function refreshHoldings() { if (!root.backend || root.runtime === null) return @@ -42,13 +51,58 @@ Item { function(err) { console.warn("feeTiers error:", err) }) } -onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() } - onRuntimeChanged: { root.refreshHoldings(); root.refreshFeeTiers() } - Component.onCompleted: { root.refreshHoldings(); root.refreshFeeTiers() } + function refreshTokens() { + if (!root.backend || root.runtime === null) + return + // Tag each request; a wallet toggle can start overlapping resolveTokens calls whose + // replies arrive out of order — drop any superseded callback (mirrors SwapPage's + // holdings-generation guard). + const generation = ++root.tokensGeneration + root.tokensLoading = true + root.runtime.watch(root.backend.resolveTokens(), + function(list) { + if (generation !== root.tokensGeneration) + return + root.resolvedTokens = list + root.tokensLoading = false + }, + function(err) { + if (generation !== root.tokensGeneration) + return + root.tokensLoading = false + console.warn("resolveTokens error:", err) + }) + } + + // Validates + persists a user-pasted custom token id, then refreshes the list and hands the + // resolved row back to the form to complete selection (or reports the failure). + function addCustomToken(tokenId) { + if (!root.backend || root.runtime === null) { + form.failTokenResolution("backend_error") + return + } + root.runtime.watch(root.backend.addCustomToken(tokenId), + function(result) { + if (result && result.ok === true) { + root.refreshTokens() + form.finishTokenResolution(result.token) + } else { + form.failTokenResolution(result && result.error ? result.error : "unresolved") + } + }, + function(err) { + console.warn("addCustomToken error:", err) + form.failTokenResolution("backend_error") + }) + } + +onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers(); root.refreshTokens() } + onRuntimeChanged: { root.refreshHoldings(); root.refreshFeeTiers(); root.refreshTokens() } + Component.onCompleted: { root.refreshHoldings(); root.refreshFeeTiers(); root.refreshTokens() } Connections { target: root.backend - function onIsWalletOpenChanged() { root.refreshHoldings() } + function onIsWalletOpenChanged() { root.refreshHoldings(); root.refreshTokens() } } readonly property int pageMargin: width < 640 ? 16 : 24 @@ -130,11 +184,11 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() } iconSize: 18 Layout.preferredWidth: 40 Layout.preferredHeight: 40 - enabled: !newPositionFlow.contextLoading && !newPositionFlow.submitting + enabled: !root.tokensLoading && !newPositionFlow.submitting Accessible.name: qsTr("Refresh position data") ToolTip.visible: hovered ToolTip.text: Accessible.name - onClicked: newPositionFlow.refreshContext(true) + onClicked: { root.refreshTokens(); root.refreshHoldings() } } } @@ -251,7 +305,9 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() } showRefreshAction: false holdings: root.holdings feeTiers: root.feeTiers - newPositionContext: newPositionFlow.newPositionContext + tokens: root.resolvedTokens + loadingTokens: root.tokensLoading + walletReady: newPositionFlow.walletStateReady flowState: newPositionFlow.viewState onQuoteRequested: function(immediate, quoteRequest) { @@ -263,12 +319,12 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() } } onTokenResolveRequested: function(tokenId) { - newPositionFlow.resolveToken(tokenId) + root.addCustomToken(tokenId) } onDraftChanged: newPositionFlow.draftChanged() onPairReset: newPositionFlow.resetPoolExistence() - onRefreshRequested: newPositionFlow.refreshContext(true) + onRefreshRequested: { root.refreshTokens(); root.refreshHoldings() } } } } @@ -277,14 +333,8 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() } Connections { target: newPositionFlow - function onTokenResolutionFinished(finalResponse) { - form.finishTokenResolution(finalResponse) - } - - function onTokenResolutionFailed(code) { - form.failTokenResolution(code) - } - + // Token resolution now goes app-side (addCustomToken → form callbacks); the flow only + // signals when a pool-existence change should re-request the quote. function onQuoteRefreshRequested(immediate) { form.requestQuote(immediate) } diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 465a405..041d2de 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,10 +1,16 @@ #include "AmmUiBackend.h" +#include #include +#include #include +#include #include #include #include +#include +#include +#include #include #include "LogosWalletProvider.h" @@ -322,6 +328,120 @@ QVariantList AmmUiBackend::feeTiers() return m_logos->amm_module.feeTiers(); } +QVariantList AmmUiBackend::resolveTokens() +{ + // The app owns the token set: the configured tokens (TOKENS_CONFIG) and the user's + // persisted custom ids — the same "known list" shape the swap side shows. Tokens the + // wallet merely holds are NOT auto-listed here; to provide liquidity with an unlisted + // token the user adds it by id (addCustomToken). The module still annotates + // holdingId/balance for whichever of these ids the wallet does hold. + const bool wallet_open = isWalletOpen(); + + QVariantList ids; + const QVariantList configured = m_logos->amm_module.tokenList(); + for (const QVariant& entry : configured) { + const QString id = entry.toMap().value(QStringLiteral("definitionId")).toString(); + if (!id.isEmpty()) + ids.append(id); + } + const QStringList custom = loadCustomTokenIds(); + for (const QString& id : custom) + ids.append(id); + + QVariantMap request; + request.insert(QStringLiteral("tokenIds"), ids); + return m_logos->amm_module.resolveTokens(request, wallet_open); +} + +QVariantMap AmmUiBackend::addCustomToken(QString tokenId) +{ + const QString id = tokenId.trimmed(); + if (id.isEmpty()) + return QVariantMap{{QStringLiteral("ok"), false}, + {QStringLiteral("error"), QStringLiteral("unresolved")}}; + + // Validate before persisting: resolve just this id and keep it only if it is a + // real fungible token (a non-fungible / unreadable id yields no row). + QVariantMap probe; + probe.insert(QStringLiteral("tokenIds"), QVariantList{id}); + const QVariantMap token = rows.first().toMap(); + const QString canonicalId = token.value(QStringLiteral("definitionId")).toString(); + if (canonicalId.isEmpty()) + return QVariantMap{{QStringLiteral("ok"), false}, + {QStringLiteral("error"), QStringLiteral("unresolved")}}; + + QStringList custom = loadCustomTokenIds(); + if (!custom.contains(canonicalId)) { + custom.append(canonicalId); + if (!saveCustomTokenIds(custom)) + return QVariantMap{{QStringLiteral("ok"), false}, + {QStringLiteral("error"), QStringLiteral("backend_error")}}; + } + return QVariantMap{{QStringLiteral("ok"), true}, {QStringLiteral("token"), token}}; + +QString AmmUiBackend::customTokenStorePath() const +{ + // A dedicated store path via CUSTOM_TOKEN_CONFIG (akin to the module's env-configured + // TOKENS_CONFIG). Otherwise per-user app data — but in a QML plugin with no + // QCoreApplication application name that can come back empty, so fall back to a fixed + // dot-dir under HOME. Persistence must never silently no-op on an empty path. + const QByteArray env = qgetenv("CUSTOM_TOKEN_CONFIG"); + if (!env.isEmpty()) + return QString::fromLocal8Bit(env); + const QString appData = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + const QString dir = !appData.isEmpty() + ? appData + : QDir(QDir::homePath()).filePath(QStringLiteral(".logos-amm")); + return QDir(dir).filePath(QStringLiteral("amm-custom-tokens.json")); +} + +QStringList AmmUiBackend::loadCustomTokenIds() const +{ + const QString path = customTokenStorePath(); + if (path.isEmpty()) + return {}; + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + const QByteArray bytes = file.readAll(); + file.close(); + + QJsonParseError error{}; + const QJsonDocument doc = QJsonDocument::fromJson(bytes, &error); + if (error.error != QJsonParseError::NoError || !doc.isArray()) + return {}; + + QStringList ids; + for (const QJsonValue& value : doc.array()) { + const QString id = value.toString().trimmed(); + if (!id.isEmpty() && !ids.contains(id)) + ids.append(id); + } + return ids; +} + +bool AmmUiBackend::saveCustomTokenIds(const QStringList& ids) const +{ + const QString path = customTokenStorePath(); + if (path.isEmpty()) { + qWarning() << "AmmUiBackend: no custom-token store path; not persisting custom tokens"; + return false; + } + QDir().mkpath(QFileInfo(path).absolutePath()); + + QJsonArray array; + for (const QString& id : ids) + array.append(id); + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qWarning() << "AmmUiBackend: cannot write custom-token store" << path << file.errorString(); + return false; + } + file.write(QJsonDocument(array).toJson(QJsonDocument::Compact)); + file.close(); + return true; +} QVariantMap AmmUiBackend::createPool(QVariantMap request) { diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index fccd4b5..a2c70d3 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -87,9 +87,20 @@ public slots: QVariantList poolList() override; // The AMM's supported fee tiers (raw bps) for the fee selector. QVariantList feeTiers() override; + // Resolves the liquidity token selector rows for the app-owned id set + // (configured ∪ persisted custom; held-but-unlisted tokens are added by id). + QVariantList resolveTokens() override; + // Validates + persists a user-pasted custom token id (see the .rep). + QVariantMap addCustomToken(QString tokenId) override; private: void syncWalletState(); + // 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. + QStringList loadCustomTokenIds() const; + bool saveCustomTokenIds(const QStringList& ids) const; + QString customTokenStorePath() const; // Publishes the new-position context PROP: a local "loading" placeholder // until wallet state (and thus the module connection) is ready, then the // module's newPositionContext for the current hints. diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 1527d2b..7da0d15 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -150,4 +150,18 @@ class AmmUiBackend // guest enforces), so the fee selector never hardcodes or drifts. The QML // formats labels and decides selectability. SLOT(QVariantList feeTiers()) + + // Resolves the liquidity token selector's rows. The backend owns the id set: + // the configured tokens (TOKENS_CONFIG) plus the user's persisted custom tokens + // (see addCustomToken) — the same "known list" shape the swap side shows. Tokens + // the wallet merely holds are NOT auto-listed; add an unlisted one by id. Returns + // [{ definitionId (base58), name, totalSupply, holdingId, balance }] — every row + // the same shape, held tokens first (holdingId "" / balance "0" when not held). + SLOT(QVariantList resolveTokens()) + + // Adds a user-pasted custom token id (base58 or hex) to the persisted set, after + // validating it resolves to a fungible definition. On success persists it (de-duped) + // 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)) } diff --git a/apps/amm/tests/README.md b/apps/amm/tests/README.md index 5284bfe..162ea16 100644 --- a/apps/amm/tests/README.md +++ b/apps/amm/tests/README.md @@ -12,6 +12,10 @@ inspector (framework from - `add-liquidity.mjs` selects the seeded **A/B** pair, asserts the CTA stays disabled until deposit amounts are entered, submits an add, and verifies the A/B pool reserves grew **on-chain**. +- `custom-token.mjs` pastes token **D**'s id (created on-chain by the setup but + deliberately **absent** from the token config) into a Liquidity token slot and + verifies the app resolves it, selects it, and **persists** it to the custom-token + store — the "add an unlisted token by id" path. No pool / submit involved. ## Isolation @@ -42,15 +46,21 @@ nix build .#test-framework -o apps/amm/result-mcp TEST_SEQUENCER_ADDR=http://127.0.0.1:3040 apps/amm/tests/testnet/setup-amm-testnet.sh # 2. Terminal 1 — launch the UI against ONLY the isolated wallet + test tokens. +# CUSTOM_TOKEN_CONFIG (where the app persists tokens added by id) defaults to the +# per-user store; set it to an isolated path so custom-token.mjs controls the store +# (it clears this file before + after running). Required for custom-token.mjs to +# avoid touching your real custom-token store. LEE_WALLET_HOME_DIR=$(pwd)/apps/amm/tests/testnet/.wallet \ AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \ TOKENS_CONFIG=$(pwd)/apps/amm/tests/testnet/amm-tokens.json \ + CUSTOM_TOKEN_CONFIG=$(pwd)/apps/amm/tests/testnet/custom-tokens.json \ nix run .#amm-ui # 3. Terminal 2 — drive a test; watch it click through the live UI. node apps/amm/tests/swap.mjs # swap against the seeded A/B pool node apps/amm/tests/create-pool.mjs # create the (unseeded) A/C pool node apps/amm/tests/add-liquidity.mjs # add liquidity to the seeded A/B pool +node apps/amm/tests/custom-token.mjs # add token D (unlisted) by id ``` Headless CI variant (no window, launches the app itself, pass/fail only): diff --git a/apps/amm/tests/custom-token.mjs b/apps/amm/tests/custom-token.mjs new file mode 100644 index 0000000..0b75369 --- /dev/null +++ b/apps/amm/tests/custom-token.mjs @@ -0,0 +1,186 @@ +// --------------------------------------------------------------------------- +// AMM UI test — add a CUSTOM (unlisted) token by id through the Liquidity view. +// +// Token D is created on-chain by the setup script but deliberately LEFT OUT of +// the token config, so it never appears in the selector on its own. This test +// pastes D's definition id into a liquidity token slot and verifies the app +// RESOLVES it (reads its on-chain definition), SELECTS it, and PERSISTS it to the +// custom-token store — the addCustomToken path. No pool / submit is involved, so +// it needs neither an open wallet nor a seeded pool. +// +// NOTE: the add path is holding-agnostic — a token resolves from its public +// definition whether or not the wallet holds it (balance shows "0" when not held). +// D happens to be held by the test wallet only because minting requires the holding +// account to sign; a genuinely un-owned token adds via exactly the same path. +// +// Prereqs in the running app (see apps/amm/tests/README.md): +// * launched against the isolated test wallet + TOKENS_CONFIG the setup writes +// (TKA, TKB, TKC — NOT token D) and CUSTOM_TOKEN_CONFIG pointing at a +// writable path (defaults below, matching the README launch line) +// * a reachable local sequencer (to read D's definition) +// --------------------------------------------------------------------------- + +import { resolve } from "node:path"; +import { rm } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; + +const fwRoot = + process.env.LOGOS_QT_MCP || + new URL("../result-mcp", import.meta.url).pathname; +const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs")); + +// The isolated test wallet home (set by the setup script) — used to resolve token +// D's deterministic definition id via the wallet CLI, the same way the setup does. +const WALLET_HOME = + process.env.LEE_WALLET_HOME_DIR || + new URL("./testnet/.wallet", import.meta.url).pathname; + +// Where the app persists custom tokens. Defaults to the isolated test store; set +// CUSTOM_TOKEN_CONFIG to override. IMPORTANT: launch the app with the SAME path +// (CUSTOM_TOKEN_CONFIG) so the test's clean-slate clears the store the app actually +// uses — otherwise the app writes to its default per-user store and the test starts +// from a stale slate. Only used to pre-clear; persistence is verified through the app. +const CUSTOM_TOKEN_CONFIG = + process.env.CUSTOM_TOKEN_CONFIG || + new URL("./testnet/custom-tokens.json", import.meta.url).pathname; + +// --- small helpers (mirror create-pool.mjs) -------------------------------- + +const ignore = async (fn) => { try { return await fn(); } catch { /* best effort */ } }; + +async function idByObjectName(app, name) { + const res = await app.findByProperty("objectName", name); + if (res.error || !res.matches || res.matches.length === 0) + throw new Error(`no object with objectName="${name}" (is the app on the Liquidity tab?)`); + return res.matches[0].id; +} + +async function prop(app, id, name) { + const props = (await app.getProperties(id)).properties || []; + const p = props.find((x) => x.name === name); + return p ? p.value : undefined; +} + +async function evaluate(app, id, expression) { + await app.inspector.send("evaluate", { expression, objectId: id }); +} + +async function saveShot(app, name) { + const shot = await ignore(() => app.screenshot()); + if (shot && shot.image) { + const path = new URL(`./${name}.png`, import.meta.url).pathname; + await import("node:fs/promises").then(({ writeFile }) => + writeFile(path, Buffer.from(shot.image, "base64"))); + console.log(` screenshot -> ${path}`); + } +} + +// Resolve token D's definition id from the test wallet (deterministic under the +// test mnemonic), the same account label the setup script mints it to. +function resolveTokenD() { + const out = execFileSync("wallet", ["account", "id", "--account-id", "token-d-def"], { + encoding: "utf8", + env: { ...process.env, LEE_WALLET_HOME_DIR: WALLET_HOME, NSSA_WALLET_HOME_DIR: WALLET_HOME }, + }); + const id = (out.match(/[1-9A-HJ-NP-Za-km-z]{32,44}/) || [])[0]; + if (!id) + throw new Error(`could not resolve token-d-def id from wallet (home=${WALLET_HOME}) — run the setup script`); + return id; +} + +// Trigger a token-list reload and wait for it to complete. resolveTokens() re-reads the +// persisted custom-token store from disk (in C++), so a token that survives a reload was +// genuinely persisted — no need to know the app's store path. +async function reloadTokens(app, pageId) { + await evaluate(app, pageId, "refreshTokens()"); + await app.waitFor( + async () => { if ((await prop(app, pageId, "tokensLoading")) === true) throw new Error("reloading"); }, + { timeout: 10000, interval: 200, description: "token reload to finish" }, + ); +} + +async function selectableIds(app, formId) { + // Read the CSV string form (var arrays don't reliably serialize over the inspector). + const csv = await prop(app, formId, "selectableTokenIdsCsv"); + return typeof csv === "string" && csv.length > 0 ? csv.split(",") : []; +} + +// --- the test --------------------------------------------------------------- + +test("amm liquidity: add a custom (unlisted) token by id", async (app) => { + const tokenD = resolveTokenD(); + console.log(` custom token D = ${tokenD}`); + + // 1. Switch to the Liquidity tab and wait for the form + page to render. + await app.waitFor( + async () => { await app.expectTexts(["Trade", "Liquidity"]); }, + { timeout: 20000, interval: 500, description: "nav bar to load" }, + ); + await ignore(() => app.click("Liquidity")); + await app.waitFor( + async () => { await idByObjectName(app, "newPositionForm"); }, + { timeout: 10000, interval: 300, description: "liquidity form to render" }, + ); + const formId = await idByObjectName(app, "newPositionForm"); + const pageId = await idByObjectName(app, "liquidityPage"); + + // 2. Clean slate: clear the isolated custom-token store the app uses and reload, so D is + // genuinely absent and pasting it must go through addCustomToken (not a direct select). + // If D is still listed after this, the app isn't using this store — launch it with + // CUSTOM_TOKEN_CONFIG pointing here (see README). + await rm(CUSTOM_TOKEN_CONFIG, { force: true }); + await reloadTokens(app, pageId); + if ((await selectableIds(app, formId)).includes(tokenD)) + throw new Error( + `token D is still listed after clearing ${CUSTOM_TOKEN_CONFIG} — launch the app with ` + + "CUSTOM_TOKEN_CONFIG set to this same path so the test controls the store (see README).", + ); + + // 3. Paste D's id into token slot A — the same entry point the token input's + // onTokenEntered uses. It's unlisted, so resolveToken routes through the app's + // addCustomToken: resolve the on-chain definition, persist, select. + await evaluate(app, formId, `resolveToken("A", "${tokenD}")`); + + // 4. Wait for the resolution to complete: D selected on side A, no resolution error. + try { + await app.waitFor( + async () => { + const err = await prop(app, formId, "tokenResolutionError"); + if (err) throw new Error(`resolution failed: ${err}`); + const selected = await prop(app, formId, "selectedTokenAId"); + if (selected !== tokenD) throw new Error(`D not selected yet (selectedTokenAId=${selected})`); + }, + { timeout: 20000, interval: 500, description: "token D resolved + selected" }, + ); + } catch (e) { + await saveShot(app, "custom-token-not-resolved"); + const err = await prop(app, formId, "tokenResolutionError"); + throw new Error(`${e.message}. tokenResolutionError=${err}`); + } + + // 5. The persistence proof: reload the list (re-reads the store from disk) and confirm D + // SURVIVES. D isn't in the token config, so if it's still selectable after a reload it + // can only have come from the persisted custom-token store — i.e. addCustomToken wrote + // it. If persistence silently failed, D would vanish here. + await reloadTokens(app, pageId); + if (!(await selectableIds(app, formId)).includes(tokenD)) { + await saveShot(app, "custom-token-not-persisted"); + throw new Error( + "token D disappeared after a reload — it resolved + selected but was NOT persisted to the " + + "custom-token store. Launch the app with CUSTOM_TOKEN_CONFIG set to a writable path (see README).", + ); + } + + console.log(" token D added as a custom token ✓ (survives a token-list reload)"); + await saveShot(app, "custom-token-added"); + + // Leave no side effects: clear the persisted custom token. The running app keeps it in + // memory until restart, but the next run's clean-slate step re-reads this cleared store. + await rm(CUSTOM_TOKEN_CONFIG, { force: true }); +}); + +run(); + +// How to run: see apps/amm/tests/README.md — same flow as create-pool.mjs, plus +// launch the UI with CUSTOM_TOKEN_CONFIG set (the setup creates token D on-chain +// but leaves it out of the token config). diff --git a/apps/amm/tests/testnet/setup-amm-testnet.sh b/apps/amm/tests/testnet/setup-amm-testnet.sh index 736b3d5..60856eb 100755 --- a/apps/amm/tests/testnet/setup-amm-testnet.sh +++ b/apps/amm/tests/testnet/setup-amm-testnet.sh @@ -2,12 +2,17 @@ # # setup-amm-testnet.sh # -------------------- -# Deploy the token/amm/twap programs, mint three fungible tokens, initialize the +# Deploy the token/amm/twap programs, mint four fungible tokens, initialize the # AMM, and create the A/B pool — from scratch — against whatever sequencer your # `wallet` / `spel` config points at. This is the prerequisite state the AMM UI -# tests exercise: swap.mjs swaps against the seeded A/B pool, and create-pool.mjs -# creates the (deliberately unseeded) A/C pool. Run it once, then launch the UI / -# run the tests. +# tests exercise: swap.mjs swaps against the seeded A/B pool, create-pool.mjs +# creates the (deliberately unseeded) A/C pool, and custom-token.mjs adds token D +# by id. Run it once, then launch the UI / run the tests. +# +# Token D is created ON-CHAIN but deliberately LEFT OUT of the written token config +# (amm-tokens.json) — it is the "custom" token the custom-token.mjs test pastes by +# id to confirm the liquidity view resolves and adds an unlisted token. Its id is +# written to custom-token.json for that test to read. # # DETERMINISTIC TEST WALLET: by default the script bootstraps an ISOLATED wallet # (git-ignored, under this folder) by restoring it from a fixed BIP-39 mnemonic @@ -64,10 +69,12 @@ TEST_SEQUENCER_ADDR="${TEST_SEQUENCER_ADDR:-}" # Deterministic accounts, created in THIS fixed order after a fresh restore so # their ids are reproducible. Resolved to ids at runtime via `wallet account id`. -# token-c-* are APPENDED (not inserted) so the pre-existing a/b/lp ids don't shift. +# token-c-*/token-d-* are APPENDED (not inserted) so the pre-existing a/b/lp ids don't shift. # Token C has no seeded pool — the create-pool UI test (apps/amm/tests/create-pool.mjs) # creates the A/C pool itself, minting its own LP holding via the app. -ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holding token-c-def token-c-holding) +# Token D is created but LEFT OUT of the token config — the custom-token UI test +# (apps/amm/tests/custom-token.mjs) adds it by id. +ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holding token-c-def token-c-holding token-d-def token-d-holding) ############################################################################### # CONFIG — non-account parameters (edit freely) @@ -86,6 +93,8 @@ AMM_IDL="artifacts/amm-idl.json" TOKEN_A_NAME="TOKEN A"; TOKEN_A_SYMBOL="TKA"; TOKEN_A_SUPPLY="1000000000000000000000"; TOKEN_A_DECIMALS=18 TOKEN_B_NAME="TOKEN B"; TOKEN_B_SYMBOL="TKB"; TOKEN_B_SUPPLY="1000000000000000000000"; TOKEN_B_DECIMALS=18 TOKEN_C_NAME="TOKEN C"; TOKEN_C_SYMBOL="TKC"; TOKEN_C_SUPPLY="1000000000000000000000"; TOKEN_C_DECIMALS=18 +# Token D is the "custom" token: created on-chain but NOT written to the token config. +TOKEN_D_NAME="TOKEN D"; TOKEN_D_SYMBOL="TKD"; TOKEN_D_SUPPLY="1000000000000000000000"; TOKEN_D_DECIMALS=18 # --- Pool inputs --- CLOCK_ACCOUNT="4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU" # canonical LEZ system clock @@ -105,6 +114,11 @@ 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" +# 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. +CUSTOM_TOKEN_CONFIG_OUT="apps/amm/tests/testnet/custom-tokens.json" + ############################################################################### # Helpers ############################################################################### @@ -263,12 +277,14 @@ TOKEN_B_HOLDING="$(acct_id token-b-holding)" || die "token-b-holding not registe USER_HOLDING_LP="$(acct_id lp-holding)" || die "lp-holding not registered" TOKEN_C_DEF="$(acct_id token-c-def)" || die "token-c-def not registered" TOKEN_C_HOLDING="$(acct_id token-c-holding)" || die "token-c-holding not registered" -for v in TOKEN_A_DEF TOKEN_A_HOLDING TOKEN_B_DEF TOKEN_B_HOLDING USER_HOLDING_LP TOKEN_C_DEF TOKEN_C_HOLDING; do +TOKEN_D_DEF="$(acct_id token-d-def)" || die "token-d-def not registered" +TOKEN_D_HOLDING="$(acct_id token-d-holding)" || die "token-d-holding not registered" +for v in TOKEN_A_DEF TOKEN_A_HOLDING TOKEN_B_DEF TOKEN_B_HOLDING USER_HOLDING_LP TOKEN_C_DEF TOKEN_C_HOLDING TOKEN_D_DEF TOKEN_D_HOLDING; do [ -n "${!v}" ] || die "failed to resolve account id for $v" done # Derived roles (the input holding signs; mint authority == holding; authority is the A holding). -TOKEN_A_MINT_AUTH="$TOKEN_A_HOLDING"; TOKEN_B_MINT_AUTH="$TOKEN_B_HOLDING"; TOKEN_C_MINT_AUTH="$TOKEN_C_HOLDING" +TOKEN_A_MINT_AUTH="$TOKEN_A_HOLDING"; TOKEN_B_MINT_AUTH="$TOKEN_B_HOLDING"; TOKEN_C_MINT_AUTH="$TOKEN_C_HOLDING"; TOKEN_D_MINT_AUTH="$TOKEN_D_HOLDING" AMM_AUTHORITY="$TOKEN_A_HOLDING" USER_HOLDING_A="$TOKEN_A_HOLDING"; USER_HOLDING_B="$TOKEN_B_HOLDING" @@ -279,6 +295,8 @@ kv "token-b-holding" "$TOKEN_B_HOLDING" kv "lp-holding" "$USER_HOLDING_LP" kv "token-c-def" "$TOKEN_C_DEF" kv "token-c-holding" "$TOKEN_C_HOLDING" +kv "token-d-def" "$TOKEN_D_DEF" +kv "token-d-holding" "$TOKEN_D_HOLDING" ############################################################################### # 2. Deploy programs @@ -320,6 +338,16 @@ run_tx strict "create fungible definition: $TOKEN_C_NAME" -- \ --holding-target-account "$TOKEN_C_HOLDING" \ --mint-authority "$TOKEN_C_MINT_AUTH" +# Token D is deliberately LEFT OUT of the token config below — the custom-token UI +# test pastes its id to add it as a custom token. Its definition must exist on-chain +# so the app can resolve it. +run_tx strict "create fungible definition: $TOKEN_D_NAME" -- \ + spel --idl "$TOKEN_IDL" --program "$TOKEN_BIN" -- new-fungible-definition \ + --name "$TOKEN_D_NAME" --total-supply "$TOKEN_D_SUPPLY" \ + --definition-target-account "$TOKEN_D_DEF" \ + --holding-target-account "$TOKEN_D_HOLDING" \ + --mint-authority "$TOKEN_D_MINT_AUTH" + ############################################################################### # 5. Verify token definitions & holdings ############################################################################### @@ -329,6 +357,8 @@ inspect "$TOKEN_IDL" "$TOKEN_B_DEF" "TokenDefinition" inspect "$TOKEN_IDL" "$TOKEN_B_HOLDING" "TokenHolding" inspect "$TOKEN_IDL" "$TOKEN_C_DEF" "TokenDefinition" inspect "$TOKEN_IDL" "$TOKEN_C_HOLDING" "TokenHolding" +inspect "$TOKEN_IDL" "$TOKEN_D_DEF" "TokenDefinition" +inspect "$TOKEN_IDL" "$TOKEN_D_HOLDING" "TokenHolding" ############################################################################### # 6. Derive AMM PDAs from the program ids + token pair @@ -400,6 +430,8 @@ inspect "$AMM_IDL" "$POOL" "PoolDefinition" ############################################################################### # 10. Write the UI token config from the deterministic accounts ############################################################################### +# NOTE: token D is intentionally NOT written here — it is the "custom" token the +# custom-token.mjs test adds by id, so it must be absent from the known list. sec "Write UI token config -> $TOKENS_CONFIG_OUT" cat > "$TOKENS_CONFIG_OUT" < "$POOLS_CONFIG_OUT" kv "wrote" "$POOLS_CONFIG_OUT" +############################################################################### +# 12. Initialize the isolated custom-token store (empty) +############################################################################### +sec "Write custom-token store -> $CUSTOM_TOKEN_CONFIG_OUT" +# Initialize the isolated custom-token store empty so a test run starts with no +# custom tokens. custom-token.mjs adds token D by id and clears this again after. +printf '%s\n' "[]" > "$CUSTOM_TOKEN_CONFIG_OUT" +kv "wrote" "$CUSTOM_TOKEN_CONFIG_OUT (empty)" + sec "Done" log "${GRN}✅ Setup complete.${RST}" kv "AMM program id" "$AMM_PID" @@ -475,6 +516,12 @@ log " ${DIM}LEE_WALLET_HOME_DIR=$TEST_WALLET_HOME \\${RST}" log " ${DIM} AMM_PROGRAM_BIN=$REPO_ROOT/$AMM_BIN \\${RST}" log " ${DIM} TOKENS_CONFIG=$REPO_ROOT/$TOKENS_CONFIG_OUT \\${RST}" 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 "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 "" log "Then in another terminal: ${DIM}node apps/amm/tests/swap.mjs${RST} (swap A/B)" log " or: ${DIM}node apps/amm/tests/create-pool.mjs${RST} (create A/C pool)" +log " or: ${DIM}node apps/amm/tests/custom-token.mjs${RST} (add token D by id)" diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index 9558b4d..1d44a56 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -22,6 +22,8 @@ char *amm_pair_ids(const char *request_json); char *amm_context(const char *request_json); +char *amm_resolve_tokens(const char *request_json); + char *amm_swap_pair(const char *request_json); char *amm_resolve_pool(const char *request_json); diff --git a/modules/amm/ffi/src/api/context.rs b/modules/amm/ffi/src/api/context.rs index 4f3d4f2..d772a54 100644 --- a/modules/amm/ffi/src/api/context.rs +++ b/modules/amm/ffi/src/api/context.rs @@ -11,7 +11,7 @@ use super::{ config::load_config, holding::{select_holding, wallet_holdings, SelectedHolding}, quote_error::issue, - ContextRequest, TokenIdsRequest, + ContextRequest, ResolveTokensRequest, TokenIdsRequest, }; use crate::account::{ account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id, @@ -133,6 +133,74 @@ pub(super) fn context(request: ContextRequest) -> Result { })) } +/// Resolves an explicit, app-provided set of token ids into selector rows — the lean, +/// stateless successor to `context`. The app owns the id set (its configured tokens plus any +/// custom/pasted ids it remembers), so there is no network envelope and no process-cached wallet +/// state here: the module reads the definitions + wallet fresh and passes them in, exactly like +/// `context` did per token. +/// +/// `token_ids` are hex (the module normalizes base58→hex at the boundary); `token_definitions` +/// are the corresponding read accounts, keyed by hex id. Every returned row has the same shape — +/// `{ definitionId (base58), name, totalSupply, holdingId, balance }` — so the app never branches +/// per row; when the wallet doesn't hold the token, `holdingId` is `""` and `balance` is `"0"`. +/// A requested id whose definition is unreadable or non-fungible is omitted; the app treats a +/// requested id with no returned row as unresolved/unavailable. +pub(super) fn resolve_tokens(request: ResolveTokensRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let Ok(config) = load_config(amm_program, &request.config) else { + return Ok(json!({ "status": "error", "code": "config_unavailable", "tokens": [] })); + }; + + let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id); + + // De-duplicate the requested ids, dropping any malformed ones. Order is irrelevant — + // the rows are sorted below (held first, then by id) like `context`. + let mut token_ids = BTreeSet::new(); + for id in &request.token_ids { + if let Ok(id) = account_id_from_hex(id, "token id") { + token_ids.insert(id); + } + } + + let mut rows = Vec::new(); + for token_id in token_ids { + let read = request + .token_definitions + .iter() + .find(|read| account_id_from_hex(&read.id, "token definition id") == Ok(token_id)); + // Only readable, fungible definitions become rows; anything else is omitted. + let Ok((name, total_supply, _metadata_id)) = + fungible_definition(read, token_id, config.token_program_id) + else { + continue; + }; + + // Uniform shape — every row carries holdingId/balance so the app never branches per row. + // A token the wallet doesn't hold gets an empty id and "0" balance. + let selected = select_holding(&holdings, token_id); + rows.push(json!({ + "definitionId": token_id.to_string(), + "name": name, + "totalSupply": total_supply.to_string(), + "holdingId": selected.as_ref().map(|holding| holding.id.to_string()).unwrap_or_default(), + "balance": selected + .as_ref() + .map_or_else(|| String::from("0"), |holding| holding.balance.to_string()), + })); + } + + rows.sort_by(|left, right| { + let held = |row: &Value| !row["holdingId"].as_str().unwrap_or_default().is_empty(); + held(right).cmp(&held(left)).then_with(|| { + left["definitionId"] + .as_str() + .cmp(&right["definitionId"].as_str()) + }) + }); + + Ok(json!({ "status": "ok", "tokens": rows })) +} + fn context_error(request: &ContextRequest, code: &str) -> Value { json!({ "status": "error", diff --git a/modules/amm/ffi/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs index c923fec..f54fb95 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -21,7 +21,7 @@ pub use request::{ AddLiquidityPlanRequest, AddLiquidityQuoteRequest, ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest, - SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, + ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest, TokenIdsRequest, }; @@ -83,6 +83,11 @@ pub fn context(request: ContextRequest) -> AmmResult { context::context(request).map_err(Into::into) } +/// Resolves an app-provided set of token ids into selector rows (lean successor to `context`). +pub fn resolve_tokens(request: ResolveTokensRequest) -> AmmResult { + context::resolve_tokens(request).map_err(Into::into) +} + /// Derives the canonical account ids for a swap pair (tokens in either order). pub fn swap_pair(request: SwapPairRequest) -> AmmResult { swap::swap_pair(request).map_err(Into::into) diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index ad8c3a6..ebedbb6 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -43,6 +43,23 @@ pub struct ContextRequest { pub resolved_token_ids: Vec, } +/// Resolves an app-provided set of token ids into selector rows (the lean successor to +/// `ContextRequest`). `token_ids` are hex — the module normalizes base58→hex and reads each +/// definition into `token_definitions` (keyed by hex id) plus the wallet accounts; the FFI is +/// stateless and reads nothing itself. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ResolveTokensRequest { + pub amm_program_id: String, + pub config: AccountRead, + #[serde(default)] + pub token_ids: Vec, + #[serde(default)] + pub wallet_accounts: Vec, + #[serde(default)] + pub token_definitions: Vec, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PairIdsRequest { diff --git a/modules/amm/ffi/src/api/tests.rs b/modules/amm/ffi/src/api/tests.rs index f374c85..09e728b 100644 --- a/modules/amm/ffi/src/api/tests.rs +++ b/modules/amm/ffi/src/api/tests.rs @@ -14,13 +14,13 @@ use token_core::{TokenDefinition, TokenHolding}; use twap_oracle_core::compute_current_tick_account_pda; use super::{ - context::{context, token_ids}, + context::{context, resolve_tokens, token_ids}, holding::{select_holding, SelectedHolding}, pair::{is_canonical_pair, pair_ids, PairIds}, quote::{div_ceil_u256, minimum_opening_pair, Q64}, swap::{swap_exact_in_plan, swap_exact_out_plan}, - ContextRequest, PairIdsRequest, SwapExactInPlanRequest, SwapExactOutPlanRequest, - TokenIdsRequest, + ContextRequest, PairIdsRequest, ResolveTokensRequest, SwapExactInPlanRequest, + SwapExactOutPlanRequest, TokenIdsRequest, }; use crate::{ account::{account_id_hex, account_read, decode_account, program_id_bytes}, @@ -291,6 +291,55 @@ fn context_selects_tokens_without_holdings() { assert!(value["tokens"][0].get("holdingId").is_none()); } +#[test] +fn resolve_tokens_returns_lean_rows_held_first_and_omits_unresolvable() { + let held = AccountId::new([2; 32]); + let listed = AccountId::new([5; 32]); + let missing = AccountId::new([9; 32]); // requested but no definition read supplied + let config_id = compute_config_pda(AMM_PROGRAM); + + let value = resolve_tokens(ResolveTokensRequest { + amm_program_id: amm_program_id(), + config: account_read(config_id, &config_account()), + token_ids: vec![ + account_id_hex(held), + account_id_hex(listed), + account_id_hex(missing), + ], + wallet_accounts: vec![account_read( + AccountId::new([6; 32]), + &token_holding(held, 42), + )], + token_definitions: vec![ + account_read(held, &token_definition("Held", 1_000)), + account_read(listed, &token_definition("Listed", 2_000)), + ], + }) + .unwrap(); + + // Held token sorts first; the requested id with no readable definition is omitted. Every row + // carries the same fields — the non-held token gets an empty holdingId and "0" balance. + assert_eq!( + value["tokens"], + json!([ + { + "definitionId": held.to_string(), + "name": "Held", + "totalSupply": "1000", + "holdingId": AccountId::new([6; 32]).to_string(), + "balance": "42", + }, + { + "definitionId": listed.to_string(), + "name": "Listed", + "totalSupply": "2000", + "holdingId": "", + "balance": "0", + }, + ]) + ); +} + #[test] fn missing_pool_snapshot_defaults_remain_real_accounts() { let id = AccountId::new([5; 32]); diff --git a/modules/amm/ffi/src/ffi.rs b/modules/amm/ffi/src/ffi.rs index bef9ed2..46635a1 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -9,7 +9,7 @@ use crate::api::{ self, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, - RemoveLiquidityQuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, + RemoveLiquidityQuoteRequest, ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest, TokenIdsRequest, }; @@ -100,6 +100,11 @@ pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char { call::(request_json, api::context) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_resolve_tokens(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::resolve_tokens) +} + #[unsafe(no_mangle)] pub extern "C" fn amm_swap_pair(request_json: *const c_char) -> *mut c_char { call::(request_json, api::swap_pair) diff --git a/modules/amm/ffi/src/lib.rs b/modules/amm/ffi/src/lib.rs index 8d06b9e..10ffad9 100644 --- a/modules/amm/ffi/src/lib.rs +++ b/modules/amm/ffi/src/lib.rs @@ -7,10 +7,11 @@ pub mod api; pub use api::{ config_id, context, create_pool_plan, create_pool_quote, fee_tiers, pair_ids, pool_id, - program_id, resolve_pool, swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan, - swap_exact_out_quote, swap_pair, token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult, - ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, - FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, - SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, - SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, WalletAccount, + program_id, resolve_pool, resolve_tokens, swap_exact_in_plan, swap_exact_in_quote, + swap_exact_out_plan, swap_exact_out_quote, swap_pair, token_ids, AccountRead, AmmApiError, + AmmResponse, AmmResult, ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, + CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, + ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, + SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, + WalletAccount, }; diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index ff592d1..2ccbe55 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -1328,6 +1328,62 @@ LogosList AmmModuleImpl::feeTiers() { return out; } +LogosList AmmModuleImpl::resolveTokens(const LogosMap& request, bool wallet_open) { + const std::string amm_program_id = ammProgramId(); + if (amm_program_id.empty()) + return LogosList::array(); + + // The config gives the token_program_id the FFI needs to decode definitions/holdings. + const FfiResult configResult = + call(amm_config_id, json{{"ammProgramId", amm_program_id}}); + if (!configResult.ok) + return LogosList::array(); + const json config = readPublicAccount(jStr(configResult.value, "configId")); + + // Normalize the app-provided ids (base58 or hex) → hex, de-dup, and read each + // definition account. The FFI is stateless, so it gets the reads pre-fetched. + const auto token_ids_it = request.find("tokenIds"); + const json token_ids = (token_ids_it != request.end() && token_ids_it->is_array()) + ? *token_ids_it + : json::array(); + + std::vector ids_vec; + ids_vec.reserve(token_ids.size()); + for (const auto& raw : token_ids) { + if (!raw.is_string()) continue; + const std::string hex = normalizeAccountId(raw.get()); + if (!hex.empty()) ids_vec.push_back(hex); + } + std::sort(ids_vec.begin(), ids_vec.end()); + ids_vec.erase(std::unique(ids_vec.begin(), ids_vec.end()), ids_vec.end()); + + json ids = json::array(); + json definitions = json::array(); + for (const auto& hex : ids_vec) { + ids.push_back(hex); + definitions.push_back(readPublicAccount(hex)); + } + + // Fresh wallet read — the selector wants current holdings/balances. + const json wallet_accounts = walletAccountReads(wallet_open, /*refresh=*/true); + + const FfiResult result = call(amm_resolve_tokens, json{ + {"ammProgramId", amm_program_id}, + {"config", config}, + {"tokenIds", ids}, + {"walletAccounts", wallet_accounts}, + {"tokenDefinitions", definitions}, + }); + if (!result.ok) + return LogosList::array(); + + LogosList out = LogosList::array(); + const auto it = result.value.find("tokens"); + if (it != result.value.end() && it->is_array()) + for (const auto& row : *it) out.push_back(row); + return out; +} + LogosMap AmmModuleImpl::newPositionContext(const LogosMap& request, bool wallet_open, bool refresh_wallet_accounts) { diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index 2067604..bec1492 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -217,6 +217,20 @@ public: /// TOKENS_CONFIG is unset / unreadable / not a JSON array. LogosList tokenList(); + /// Resolves an app-provided set of token ids into liquidity selector rows. + /// `request` carries `{ tokenIds: [, …] }` (base58 or hex, + /// normalized to hex here) — the app owns the set: its configured tokens plus any + /// custom/pasted ids it remembers (held-but-unlisted tokens are not auto-added by + /// the app). Reads each definition and (when `wallet_open`) the wallet, then returns + /// `[{ definitionId (base58), name, totalSupply, holdingId, balance }]`. Every + /// row has the same fields — a token the wallet doesn't hold gets `holdingId:""` + /// and `balance:"0"` — held tokens first. A requested id whose definition is + /// unreadable / non-fungible is omitted (the app treats a missing row as + /// unresolved). Empty list if AMM_PROGRAM_BIN is unset or the config read fails. + /// (`tokenIds` is wrapped in a map, not passed as a bare list, because the + /// universal-module glue only supports map/scalar inputs.) + LogosList resolveTokens(const LogosMap& request, bool wallet_open); + /// New-position (add-liquidity) view state: reads the AMM config + the /// user's wallet accounts and returns the new-position context map the /// UI renders (available tokens, fee tiers, warnings). `wallet_open` gates