mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user