feat(apps/amm): pick the token account per swap side

Add a per-slot account selector to the swap card's sell/buy inputs, sourced
from the wallet's token holdings, so a user holding a token in several accounts
chooses which funds each side. A single holding auto-selects.

- SwapPage fetches backend.tokenHoldings(), refetching when the wallet opens
- TokenInput embeds ProgramAccountSelector (half width, right-aligned) in a
  full-width row below the amount/token row; exposes it as selectedHoldingId
- SwapCard drives swapExactInput/Output with the selected holdings and blocks
  submit ("Select token accounts") until both are chosen
- ProgramAccountSelector gains showWhenSingle + textAlignment opt-ins
This commit is contained in:
r4bbit
2026-08-11 12:04:48 +02:00
parent 2a3be1278a
commit 968001066c
4 changed files with 203 additions and 97 deletions
+19 -2
View File
@@ -17,6 +17,12 @@ Rectangle {
// Real backend replica (logos.module("amm_ui")), wired from SwapPage.
property var backend: null
// The wallet's token holdings (backend.tokenHoldings()), fed to each slot's
// account selector; the chosen input/output holding ids drive the submit.
property var holdings: []
readonly property string sellHolding: sellTokenInput.selectedHoldingId
readonly property string buyHolding: buyTokenInput.selectedHoldingId
property var sellToken: null
property var buyToken: null
property string sellInput: ""
@@ -389,6 +395,10 @@ Rectangle {
&& root.poolResolved && root.poolExists
&& !outputExceedsLiquidity && !root.swapInProgress
&& !root.quoteLoading && root.walletOpen
// Both holdings must be chosen (auto-selected
// when the wallet has exactly one per token).
&& root.sellHolding.length > 0
&& root.buyHolding.length > 0
readonly property string submitButtonText: {
if (!tokensSelected) return qsTr("Select tokens")
@@ -400,6 +410,8 @@ Rectangle {
if (outputExceedsLiquidity) return qsTr("Insufficient liquidity")
if (parsedSellAmount <= 0 || parsedBuyAmount <= 0) return qsTr("Amount too small")
if (!root.walletOpen) return qsTr("Connect wallet to swap")
if (root.sellHolding.length === 0 || root.buyHolding.length === 0)
return qsTr("Select token accounts")
return qsTr("Swap")
}
@@ -460,8 +472,9 @@ Rectangle {
var deadline = "18446744073709551615"
var inDef = root.sellToken.definitionId
var outDef = root.buyToken.definitionId
var inHolding = root.sellToken.holding
var outHolding = root.buyToken.holding
// Holdings come from the per-slot account selector, not the token config.
var inHolding = root.sellHolding
var outHolding = root.buyHolding
// The on-chain guard is the quote's exact-integer bound: the exact-input
// floor (minReceivedRaw) or the exact-output ceiling (maxInRaw). The typed
@@ -514,6 +527,7 @@ Rectangle {
spacing: 0
TokenInput {
id: sellTokenInput
Layout.fillWidth: true
theme: root.theme
label: "Sell"
@@ -521,6 +535,7 @@ Rectangle {
buttonObjectName: "swapSellTokenButton"
amount: root.sellDisplay
token: root.sellToken
holdings: root.holdings
active: root.editingSide === "sell"
// Sell amount is sent to the backend as a raw base-units integer
// string; reject fractional entry rather than fail opaquely.
@@ -574,6 +589,7 @@ Rectangle {
}
TokenInput {
id: buyTokenInput
Layout.fillWidth: true
theme: root.theme
label: "Buy"
@@ -581,6 +597,7 @@ Rectangle {
buttonObjectName: "swapBuyTokenButton"
amount: root.buyDisplay
token: root.buyToken
holdings: root.holdings
active: root.editingSide === "buy"
// Exact-output amount is sent to the backend as a raw base-units
// integer string; reject fractional entry rather than fail opaquely.
+137 -94
View File
@@ -1,5 +1,6 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import Logos.Wallet
import "TokenVisuals.js" as TokenVisuals
Rectangle {
@@ -10,6 +11,11 @@ Rectangle {
property string amount: ""
property string usdValue: ""
property var token: null
// The wallet's token holdings (backend.tokenHoldings()); the selector narrows
// them to this slot's token. The chosen holding id is exposed as selectedHoldingId.
property var holdings: []
readonly property string tokenDefinitionIdHex: root.token ? String(root.token.definitionId || "") : ""
readonly property string selectedHoldingId: accountSelector.selectedAccountId
property bool active: true
// When true, restrict input to digits only — used for the sell-amount
// field, whose value is sent to the backend as a raw base-units integer
@@ -34,123 +40,160 @@ Rectangle {
radius: 16
color: root.active ? theme.colors.inputBg : theme.colors.panelBg
implicitHeight: 110
// Height grows with the content: the amount/token row plus the full-width
// account selector below it (34/20/0px depending on how many holdings match).
implicitHeight: tiContent.implicitHeight + 28
Behavior on color { ColorAnimation { duration: 300 } }
RowLayout {
anchors.fill: parent
ColumnLayout {
id: tiContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 16
anchors.rightMargin: 16
anchors.topMargin: 14
anchors.bottomMargin: 14
spacing: 8
spacing: 10
ColumnLayout {
RowLayout {
Layout.fillWidth: true
spacing: 4
spacing: 8
Text {
text: root.label
color: theme.colors.textSecondary
font.pixelSize: 14
}
Item {
ColumnLayout {
Layout.fillWidth: true
height: 44
spacing: 4
TextInput {
id: tiInput
anchors.fill: parent
color: root.active ? theme.colors.textPrimary : theme.colors.textSecondary
font.pixelSize: 36
font.weight: Font.Bold
selectionColor: theme.colors.selection
clip: true
onTextEdited: {
if (root.digitsOnly) {
// Amounts are base units (integers) — strip any
// character that slips past the validator (e.g.
// via paste) before it reaches the backend.
var filtered = text.replace(/[^0-9]/g, "")
if (filtered !== text)
text = filtered // does not re-trigger onTextEdited
root.inputEdited(filtered)
} else {
root.inputEdited(text)
Text {
text: root.label
color: theme.colors.textSecondary
font.pixelSize: 14
}
Item {
Layout.fillWidth: true
height: 44
TextInput {
id: tiInput
anchors.fill: parent
color: root.active ? theme.colors.textPrimary : theme.colors.textSecondary
font.pixelSize: 36
font.weight: Font.Bold
selectionColor: theme.colors.selection
clip: true
onTextEdited: {
if (root.digitsOnly) {
// Amounts are base units (integers) — strip any
// character that slips past the validator (e.g.
// via paste) before it reaches the backend.
var filtered = text.replace(/[^0-9]/g, "")
if (filtered !== text)
text = filtered // does not re-trigger onTextEdited
root.inputEdited(filtered)
} else {
root.inputEdited(text)
}
}
validator: RegularExpressionValidator {
regularExpression: root.digitsOnly ? /^[0-9]*$/ : /^[0-9]*\.?[0-9]*$/
}
}
validator: RegularExpressionValidator {
regularExpression: root.digitsOnly ? /^[0-9]*$/ : /^[0-9]*\.?[0-9]*$/
Text {
anchors.fill: parent
text: "0"
color: theme.colors.textPlaceholder
font: tiInput.font
visible: tiInput.text === "" && !tiInput.activeFocus
verticalAlignment: Text.AlignVCenter
}
}
Text {
anchors.fill: parent
text: "0"
color: theme.colors.textPlaceholder
font: tiInput.font
visible: tiInput.text === "" && !tiInput.activeFocus
verticalAlignment: Text.AlignVCenter
text: root.usdValue
color: theme.colors.textSecondary
font.pixelSize: 13
visible: root.usdValue !== ""
}
}
Text {
text: root.usdValue
color: theme.colors.textSecondary
font.pixelSize: 13
visible: root.usdValue !== ""
Rectangle {
id: tokenButton
Layout.alignment: Qt.AlignVCenter
height: 40
radius: 20
color: tokenBtnHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg
implicitWidth: tokenBtnRow.implicitWidth + 24
Behavior on color { ColorAnimation { duration: 120 } }
RowLayout {
id: tokenBtnRow
anchors.centerIn: parent
spacing: 6
Rectangle {
width: 24; height: 24; radius: 12
color: root.token ? TokenVisuals.colorFor(root.token.symbol) : theme.colors.noTokenCircle
visible: root.token !== null
Text {
anchors.centerIn: parent
text: root.token ? TokenVisuals.letterFor(root.token.symbol) : ""
color: "#ffffff"
font.pixelSize: 10
font.weight: Font.Bold
}
}
Text {
text: root.token ? root.token.symbol : "Select token"
color: theme.colors.textPrimary
font.pixelSize: 15
font.weight: root.token ? Font.Medium : Font.Normal
}
Text {
text: "▼"
color: theme.colors.textSecondary
font.pixelSize: 10
}
}
MouseArea {
id: tokenBtnHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.tokenClicked()
}
}
}
Rectangle {
id: tokenButton
height: 40
radius: 20
color: tokenBtnHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg
implicitWidth: tokenBtnRow.implicitWidth + 24
Behavior on color { ColorAnimation { duration: 120 } }
RowLayout {
id: tokenBtnRow
anchors.centerIn: parent
spacing: 6
Rectangle {
width: 24; height: 24; radius: 12
color: root.token ? TokenVisuals.colorFor(root.token.symbol) : theme.colors.noTokenCircle
visible: root.token !== null
Text {
anchors.centerIn: parent
text: root.token ? TokenVisuals.letterFor(root.token.symbol) : ""
color: "#ffffff"
font.pixelSize: 10
font.weight: Font.Bold
}
}
Text {
text: root.token ? root.token.symbol : "Select token"
color: theme.colors.textPrimary
font.pixelSize: 15
font.weight: root.token ? Font.Medium : Font.Normal
}
Text {
text: "▼"
color: theme.colors.textSecondary
font.pixelSize: 10
}
}
MouseArea {
id: tokenBtnHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.tokenClicked()
}
// Which of the user's holdings for this token to use, full width below the
// amount/token row. Always shown when there is at least one holding
// (auto-selecting the single one); "No funds" when there are none.
ProgramAccountSelector {
id: accountSelector
// Half width, pinned to the right edge under the token button.
Layout.alignment: Qt.AlignRight
Layout.preferredWidth: Math.round(tiContent.width / 2)
sourceModel: root.holdings
accountType: "TokenHolding"
// The swap view is hex end-to-end: the token's definitionId is hex, so match the
// holding's hex definitionIdHex (tokenHoldings emits both — `definitionId` is base58
// for the liquidity view). Filtering on `definitionId` here never matches a hex
// stateValue, so every token would show "No funds".
stateField: "definitionIdHex"
stateValue: root.tokenDefinitionIdHex
selectionMode: ProgramAccountSelector.Input
showWhenSingle: true
textAlignment: Text.AlignRight
backgroundColor: root.theme.colors.panelBg
hoverColor: root.theme.colors.panelHoverBg
textColor: root.theme.colors.textPrimary
secondaryTextColor: root.theme.colors.textSecondary
borderColor: root.theme.colors.borderStrong
focusColor: root.theme.colors.ctaBg
}
}
}
+35
View File
@@ -17,14 +17,48 @@ Item {
// until the backend is ready and the call resolves.
property var tokens: []
// The wallet's token holdings (backend.tokenHoldings()), fed to the swap card's
// account selectors. Refetched when the wallet opens (it needs an open wallet).
property var holdings: []
// Monotonic tag for refreshHoldings() requests: a callback applies only if it is still the
// latest, so an out-of-order tokenHoldings reply can't clobber the current wallet's list.
property int holdingsGeneration: 0
function refreshHoldings() {
if (!root.backend)
return
// onBackendChanged and onIsWalletOpenChanged can start overlapping tokenHoldings
// requests (a wallet-open refresh racing a just-closed one, or a fast wallet switch)
// whose replies may arrive out of order. Tag each request and drop any callback a newer
// request has superseded, so a stale (empty, or previous-wallet) list can't overwrite
// the current holdings.
const generation = ++root.holdingsGeneration
logos.watch(root.backend.tokenHoldings(),
function(list) {
if (generation === root.holdingsGeneration)
root.holdings = list
},
function(err) {
if (generation === root.holdingsGeneration)
console.warn("tokenHoldings error:", err)
})
}
onBackendChanged: {
if (root.backend) {
logos.watch(root.backend.tokenList(),
function(list) { root.tokens = list },
function(err) { console.warn("tokenList error:", err) })
root.refreshHoldings()
}
}
Connections {
target: root.backend
function onIsWalletOpenChanged() { root.refreshHoldings() }
}
QtObject {
id: pageTheme
property bool isDark: true
@@ -104,6 +138,7 @@ Item {
Layout.alignment: Qt.AlignHCenter
theme: pageTheme
tokens: root.tokens
holdings: root.holdings
backend: root.backend
width: Math.min(480, root.width - 32)
@@ -29,6 +29,13 @@ Item {
property color secondaryTextColor: "#a1a1aa"
property color borderColor: "#52525b"
property color focusColor: "#f26a21"
// Show the combo even when only one account matches. By default a single match
// auto-selects and hides (no choice to make); consumers that want the chosen
// holding always visible set this true.
property bool showWhenSingle: false
// Horizontal alignment of the selector's text (closed combo, dropdown rows and
// the empty "No funds" label). Defaults to left; consumers can right-align it.
property int textAlignment: Text.AlignLeft
property int modelRevision: 0
readonly property bool criteriaReady: root.accountType.length > 0
@@ -50,7 +57,8 @@ Item {
: "0"
readonly property bool showCombo: root.selectionMode === ProgramAccountSelector.Output
|| (root.criteriaReady
&& root.matchingAccounts.length > 1)
&& root.matchingAccounts.length
> (root.showWhenSingle ? 0 : 1))
readonly property bool showEmptyInput: root.selectionMode === ProgramAccountSelector.Input
&& root.criteriaReady
&& root.matchingAccounts.length === 0
@@ -110,6 +118,7 @@ Item {
color: root.secondaryTextColor
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
horizontalAlignment: root.textAlignment
Accessible.role: Accessible.StaticText
Accessible.name: text
}
@@ -140,6 +149,7 @@ Item {
color: accountCombo.enabled ? root.textColor : root.secondaryTextColor
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
horizontalAlignment: root.textAlignment
elide: Text.ElideMiddle
}
@@ -179,6 +189,7 @@ Item {
color: root.textColor
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
horizontalAlignment: root.textAlignment
elide: Text.ElideMiddle
}