diff --git a/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml b/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml index df3919e..ef39a67 100644 --- a/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml +++ b/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml @@ -3,6 +3,8 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import "../shared" + ColumnLayout { id: root diff --git a/apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml b/apps/amm/qml/components/shared/AmmTokenSelectButton.qml similarity index 100% rename from apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml rename to apps/amm/qml/components/shared/AmmTokenSelectButton.qml diff --git a/apps/amm/qml/components/shared/TokenList.js b/apps/amm/qml/components/shared/TokenList.js new file mode 100644 index 0000000..0bba877 --- /dev/null +++ b/apps/amm/qml/components/shared/TokenList.js @@ -0,0 +1,102 @@ +.pragma library + +// The app's one token list, shared by the swap and liquidity views so a token +// never appears in one picker and not the other. +// +// Two sources have to be reconciled: +// * tokenList() — TOKENS_CONFIG verbatim. Carries the display `symbol`, +// which is the only place a symbol exists. No chain check. +// * resolveTokens() — the same configured ids PLUS the user's persisted custom +// ids, each read on-chain. Drops any id whose definition +// isn't a readable fungible token owned by the configured +// TokenProgram, and carries holdingId/balance. Ids come +// back as canonical base58. +// +// Merging them keeps every configured token listed (a dropped one is marked +// unselectable rather than vanishing), adds the custom tokens, and puts the +// configured symbol back on rows that resolveTokens returned without one. + +// resolveTokens() echoes ids as base58, so only a base58-configured id can be +// compared against a resolved row here. See merge(). +function isBase58Id(tokenId) { + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(String(tokenId || "")) +} + +function configTokenFor(configTokens, definitionId) { + for (var i = 0; i < configTokens.length; ++i) { + if (String(configTokens[i].definitionId || "") === definitionId) + return configTokens[i] + } + return null +} + +// Rows are `{ definitionId, symbol, name, totalSupply, holdingId, balance }`, +// plus `selectable: false` and a `code` on a configured token that did not +// resolve. `unresolvedCode` is the reason code such a row carries. +function merge(configTokens, resolvedTokens, unresolvedCode) { + var config = configTokens || [] + var resolved = resolvedTokens || [] + + // No resolution data at all (module unavailable, AMM not initialized, or the + // call simply hasn't landed) is "unknown", not "everything is broken" — + // greying out the whole list would make the view unusable. Fall back to the + // configured list as-is, which is what the swap view always did. + if (resolved.length === 0) + return config.map(function(token) { + return { + "definitionId": String(token.definitionId || ""), + "symbol": String(token.symbol || ""), + "name": String(token.name || ""), + "totalSupply": "0", + "holdingId": "", + "balance": "0" + } + }) + + var rows = [] + var resolvedIds = {} + + for (var i = 0; i < resolved.length; ++i) { + var row = resolved[i] + var id = String(row.definitionId || "") + var configured = configTokenFor(config, id) + resolvedIds[id] = true + rows.push({ + "definitionId": id, + "symbol": configured ? String(configured.symbol || "") : "", + // Prefer the configured name so both pickers agree; fall back to the + // on-chain name for a custom token the config doesn't carry. + "name": configured && configured.name + ? String(configured.name) : String(row.name || ""), + "totalSupply": row.totalSupply, + "holdingId": row.holdingId, + "balance": row.balance + }) + } + + for (var j = 0; j < config.length; ++j) { + var token = config[j] + var configuredId = String(token.definitionId || "") + if (configuredId.length === 0 || resolvedIds[configuredId]) + continue + // A hex-configured id can't be compared against the base58 rows above, so + // synthesizing a row risks duplicating a token that did resolve. Skip it + // and fall back to the pre-merge behaviour for that entry. + if (!isBase58Id(configuredId)) + continue + rows.push({ + "definitionId": configuredId, + "symbol": String(token.symbol || ""), + "name": String(token.name || ""), + "totalSupply": "0", + "holdingId": "", + "balance": "0", + // Listed but greyed out, with the reason on hover, instead of + // vanishing with no explanation. + "selectable": false, + "code": unresolvedCode || "token_unresolved" + }) + } + + return rows +} diff --git a/apps/amm/qml/components/liquidity/TokenSelectorModal.qml b/apps/amm/qml/components/shared/TokenSelectorModal.qml similarity index 97% rename from apps/amm/qml/components/liquidity/TokenSelectorModal.qml rename to apps/amm/qml/components/shared/TokenSelectorModal.qml index c07b725..68b0363 100644 --- a/apps/amm/qml/components/liquidity/TokenSelectorModal.qml +++ b/apps/amm/qml/components/shared/TokenSelectorModal.qml @@ -4,6 +4,8 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import "TokenVisuals.js" as TokenVisuals + Popup { id: root @@ -472,14 +474,15 @@ Popup { } function tokenColor(token) { - return token && token.color ? token.color : root.theme.colors.noTokenCircle + // Token config carries no color field; derive it from the symbol the + // same way TokenInput / PoolsPage do (see TokenVisuals.js). Fall back to + // the name for resolved tokens (e.g. custom ids) that carry no symbol. + return token ? TokenVisuals.colorFor(root.tokenSymbol(token) || root.tokenName(token)) + : root.theme.colors.noTokenCircle } function tokenLetter(token) { - if (token && token.letter) - return String(token.letter) - var label = root.tokenSymbol(token) || root.tokenName(token) - return label.length > 0 ? label.charAt(0).toUpperCase() : "" + return token ? TokenVisuals.letterFor(root.tokenSymbol(token) || root.tokenName(token)) : "" } function shortAddress(value) { diff --git a/apps/amm/qml/components/swap/TokenVisuals.js b/apps/amm/qml/components/shared/TokenVisuals.js similarity index 100% rename from apps/amm/qml/components/swap/TokenVisuals.js rename to apps/amm/qml/components/shared/TokenVisuals.js diff --git a/apps/amm/qml/components/swap/TokenInput.qml b/apps/amm/qml/components/swap/TokenInput.qml index 01eae43..01e1a1f 100644 --- a/apps/amm/qml/components/swap/TokenInput.qml +++ b/apps/amm/qml/components/swap/TokenInput.qml @@ -1,7 +1,7 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 import Logos.Wallet -import "TokenVisuals.js" as TokenVisuals +import "../shared/TokenVisuals.js" as TokenVisuals Rectangle { id: root diff --git a/apps/amm/qml/components/swap/TokenListItem.qml b/apps/amm/qml/components/swap/TokenListItem.qml deleted file mode 100644 index 49f52f9..0000000 --- a/apps/amm/qml/components/swap/TokenListItem.qml +++ /dev/null @@ -1,88 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Layouts 1.15 -import "TokenVisuals.js" as TokenVisuals - -Item { - id: root - - property var theme - property string tokenName: "" - property string tokenSymbol: "" - property string tokenDefinitionId: "" - // When true, the token is already selected on the other side of the swap, - // so it's shown dimmed and can't be picked (a pool needs two distinct - // tokens — picking the same one both sides panics amm_core's pool PDA). - property bool disabled: false - - signal clicked() - - implicitHeight: 56 - opacity: root.disabled ? 0.35 : 1.0 - - Rectangle { - anchors.fill: parent - radius: 12 - color: (!root.disabled && hoverArea.containsMouse) ? theme.colors.panelBg : "transparent" - Behavior on color { ColorAnimation { duration: 120 } } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 8 - spacing: 12 - - Rectangle { - width: 36; height: 36; radius: 18 - color: TokenVisuals.colorFor(root.tokenSymbol) - Text { - anchors.centerIn: parent - text: TokenVisuals.letterFor(root.tokenSymbol) - color: "#ffffff" - font.pixelSize: 14 - font.weight: Font.Bold - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - Text { - text: root.tokenName - color: theme.colors.textPrimary - font.pixelSize: 15 - elide: Text.ElideRight - Layout.fillWidth: true - } - - RowLayout { - spacing: 6 - Text { text: root.tokenSymbol; color: theme.colors.textSecondary; font.pixelSize: 12 } - Text { - text: root.tokenDefinitionId !== "" - ? root.tokenDefinitionId.substring(0, 6) + "..." + root.tokenDefinitionId.slice(-4) - : "" - color: theme.colors.textPlaceholder - font.pixelSize: 12 - } - } - } - - Text { - visible: root.disabled - text: qsTr("Selected") - color: theme.colors.textSecondary - font.pixelSize: 12 - } - } - - MouseArea { - id: hoverArea - anchors.fill: parent - enabled: !root.disabled - hoverEnabled: !root.disabled - cursorShape: root.disabled ? Qt.ArrowCursor : Qt.PointingHandCursor - onClicked: root.clicked() - } - } -} diff --git a/apps/amm/qml/components/swap/TokenSelectorModal.qml b/apps/amm/qml/components/swap/TokenSelectorModal.qml deleted file mode 100644 index 6260903..0000000 --- a/apps/amm/qml/components/swap/TokenSelectorModal.qml +++ /dev/null @@ -1,190 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import "TokenVisuals.js" as TokenVisuals - -Item { - id: root - - // Stable hook so UI tests can read `visible` to know the picker is open. - objectName: "tokenSelectorModal" - - property var theme - property var tokens: [] - property string searchText: "" - // definitionId of the token already chosen on the other side of the swap. - // That token is shown disabled here so the two sides can never match (a - // same-token pool has no PDA — it panics amm_core). - property string disabledDefinitionId: "" - - signal tokenSelected(var token) - - visible: false - - function open() { - root.visible = true - searchText = "" - searchField.text = "" - searchField.forceActiveFocus() - } - - function close() { - root.visible = false - } - - Rectangle { - anchors.fill: parent - color: Qt.rgba(0, 0, 0, 0.4) - MouseArea { anchors.fill: parent; onClicked: root.close() } - } - - Rectangle { - anchors.centerIn: parent - width: Math.min(480, root.width - 32) - height: Math.min(600, root.height - 64) - radius: 24 - color: theme.colors.cardBg - border.color: theme.colors.border - border.width: 1 - - Behavior on color { ColorAnimation { duration: 300 } } - - MouseArea { anchors.fill: parent; onClicked: {} } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 20 - spacing: 16 - - RowLayout { - Layout.fillWidth: true - Text { - Layout.fillWidth: true - text: "Select a token" - color: theme.colors.textPrimary - font.pixelSize: 18 - font.weight: Font.Bold - } - Rectangle { - width: 32; height: 32; radius: 16 - color: closeHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg - Behavior on color { ColorAnimation { duration: 120 } } - Text { anchors.centerIn: parent; text: "✕"; color: theme.colors.textSecondary; font.pixelSize: 14 } - MouseArea { - id: closeHover - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.close() - } - } - } - - Rectangle { - Layout.fillWidth: true - height: 48 - radius: 16 - color: theme.colors.inputBg - border.color: searchField.activeFocus ? theme.colors.borderStrong : theme.colors.border - border.width: 1 - Behavior on border.color { ColorAnimation { duration: 150 } } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 14 - anchors.rightMargin: 14 - spacing: 8 - Text { text: "⌕"; color: theme.colors.textSecondary; font.pixelSize: 20 } - TextInput { - id: searchField - Layout.fillWidth: true - color: theme.colors.textPrimary - font.pixelSize: 15 - selectionColor: theme.colors.selection - onTextChanged: root.searchText = text - Text { - anchors.fill: parent - text: "Search tokens" - color: theme.colors.textPlaceholder - font: searchField.font - visible: searchField.text === "" && !searchField.activeFocus - verticalAlignment: Text.AlignVCenter - } - } - } - } - - Text { text: "Popular tokens"; color: theme.colors.textSecondary; font.pixelSize: 13 } - - Flow { - Layout.fillWidth: true - spacing: 8 - Repeater { - model: root.tokens.slice(0, 5) - delegate: Rectangle { - id: pill - readonly property bool isDisabled: - root.disabledDefinitionId !== "" && - modelData.definitionId === root.disabledDefinitionId - height: 40 - radius: 20 - color: (!pill.isDisabled && pillHover.containsMouse) ? theme.colors.panelHoverBg : theme.colors.panelBg - border.color: theme.colors.border - border.width: 1 - width: pillRow.implicitWidth + 24 - opacity: pill.isDisabled ? 0.35 : 1.0 - Behavior on color { ColorAnimation { duration: 120 } } - RowLayout { - id: pillRow - anchors.centerIn: parent - spacing: 6 - Rectangle { - width: 22; height: 22; radius: 11 - color: TokenVisuals.colorFor(modelData.symbol) - Text { anchors.centerIn: parent; text: TokenVisuals.letterFor(modelData.symbol); color: "#ffffff"; font.pixelSize: 10; font.weight: Font.Bold } - } - Text { text: modelData.symbol; color: theme.colors.textPrimary; font.pixelSize: 13; font.weight: Font.Medium } - } - MouseArea { - id: pillHover - anchors.fill: parent - enabled: !pill.isDisabled - hoverEnabled: !pill.isDisabled - cursorShape: pill.isDisabled ? Qt.ArrowCursor : Qt.PointingHandCursor - onClicked: root.tokenSelected(modelData) - } - } - } - } - - Text { text: "Tokens by 24H volume"; color: theme.colors.textSecondary; font.pixelSize: 13 } - - ListView { - id: tokenList - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - spacing: 2 - model: root.tokens.filter(function(t) { - if (root.searchText === "") return true - var q = root.searchText.toLowerCase() - return t.symbol.toLowerCase().indexOf(q) !== -1 || - t.name.toLowerCase().indexOf(q) !== -1 - }) - delegate: TokenListItem { - width: tokenList.width - // Stable hook for UI tests to enumerate the list (QML - // file-defined types aren't matchable via findByType). - objectName: "tokenListItem" - theme: root.theme - tokenName: modelData.name - tokenSymbol: modelData.symbol - tokenDefinitionId: modelData.definitionId - disabled: root.disabledDefinitionId !== "" && - modelData.definitionId === root.disabledDefinitionId - onClicked: root.tokenSelected(modelData) - } - } - } - } -} diff --git a/apps/amm/qml/pages/PoolsPage.qml b/apps/amm/qml/pages/PoolsPage.qml index fcf3026..ae0d6c0 100644 --- a/apps/amm/qml/pages/PoolsPage.qml +++ b/apps/amm/qml/pages/PoolsPage.qml @@ -5,7 +5,7 @@ import QtQuick.Controls import QtQuick.Layouts import "../components/liquidity" -import "../components/swap/TokenVisuals.js" as TokenVisuals +import "../components/shared/TokenVisuals.js" as TokenVisuals Item { id: root diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 7e1eeec..b76b338 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -144,10 +144,6 @@ Item { onRequestTokenSelect: function(side) { tokenModal.targetSide = side - // Disable the token already picked on the opposite side so the - // two sides can't match (a same-token pool panics amm_core). - var other = side === "sell" ? swapCard.buyToken : swapCard.sellToken - tokenModal.disabledDefinitionId = other ? other.definitionId : "" tokenModal.open() } @@ -177,7 +173,6 @@ Item { TokenSelectorModal { id: tokenModal - anchors.fill: parent z: 10 theme: pageTheme tokens: root.tokens @@ -185,6 +180,15 @@ Item { property string targetSide: "sell" onTokenSelected: function(tok) { + // Prevent picking the same token on both sides (a same-token pool + // panics amm_core). The consolidated modal no longer takes a + // disabled id, so enforce it at selection time. + var other = targetSide === "sell" ? swapCard.buyToken : swapCard.sellToken + if (other && tok + && String(tok.definitionId || "") === String(other.definitionId || "")) { + tokenModal.close() + return + } swapCard.setToken(targetSide, tok) tokenModal.close() } diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index fcc5106..efe6227 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -393,7 +393,32 @@ QVariantList AmmUiBackend::resolveTokens() QVariantMap request; request.insert(QStringLiteral("tokenIds"), ids); - return m_logos->amm_module.resolveTokens(request, wallet_open); + QVariantList rows = m_logos->amm_module.resolveTokens(request, wallet_open); + + // The module resolves on-chain fields (definitionId/name/holding/balance) but + // not the UI-only `symbol`, which lives in TOKENS_CONFIG. Re-attach it here so + // the liquidity token picker derives the same colored avatars as the swap side + // (TokenVisuals derives color/letter from the symbol). Custom ids not in the + // config keep no symbol; the picker falls back to the name for those. + for (QVariant& row : rows) { + QVariantMap token = row.toMap(); + if (!token.value(QStringLiteral("symbol")).toString().isEmpty()) { + row = token; + continue; + } + const QString id = token.value(QStringLiteral("definitionId")).toString(); + for (const QVariant& entry : configured) { + const QVariantMap cfg = entry.toMap(); + if (cfg.value(QStringLiteral("definitionId")).toString() == id) { + token.insert(QStringLiteral("symbol"), cfg.value(QStringLiteral("symbol"))); + if (token.value(QStringLiteral("name")).toString().isEmpty()) + token.insert(QStringLiteral("name"), cfg.value(QStringLiteral("name"))); + break; + } + } + row = token; + } + return rows; } QVariantMap AmmUiBackend::addCustomToken(QString tokenId)