fix(apps/amm): one token list and one token picker for both views

The two views listed different tokens: swap rendered tokenList()
(TOKENS_CONFIG verbatim, no chain check) while liquidity rendered
resolveTokens(), which reads each id on-chain and silently drops any whose
definition isn't a readable fungible token owned by the configured
TokenProgram.
This commit is contained in:
r4bbit
2026-08-20 15:21:47 +02:00
parent 627fcfa4e2
commit 4651f28a05
11 changed files with 149 additions and 291 deletions
@@ -3,6 +3,8 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import "../shared"
ColumnLayout {
id: root
+102
View File
@@ -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
}
@@ -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) {
+1 -1
View File
@@ -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
@@ -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()
}
}
}
@@ -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)
}
}
}
}
}
+1 -1
View File
@@ -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
+9 -5
View File
@@ -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()
}
+26 -1
View File
@@ -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)