mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
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:
@@ -3,6 +3,8 @@ pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "../shared"
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+8
-5
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user