feat(wallet): add reusable program account selector

This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-10 11:23:44 -03:00
parent 4363f13912
commit a633adce20
24 changed files with 1562 additions and 169 deletions
+8 -8
View File
@@ -180,11 +180,11 @@ Swap view stays disabled (no pool can be resolved).
### Token list config (required for the Swap token picker)
The Swap view's token picker is config-driven: it doesn't derive tokens from
chain state, it reads a flat JSON list from the `TOKENS_CONFIG` environment
variable (absolute path). Each entry needs, at minimum, the token's
`definitionId` and **your own** `holding` account address for that token (the
account the wallet will sign transfers from/to for that token):
The Swap view's token picker is config-driven: it doesn't derive token
definitions from chain state, it reads a flat JSON list from the
`TOKENS_CONFIG` environment variable (absolute path). Each entry needs, at
minimum, a `definitionId`. Source and destination TokenHoldings come from the
connected wallet:
```json
[
@@ -192,7 +192,6 @@ account the wallet will sign transfers from/to for that token):
"symbol": "TKA",
"name": "Token A",
"definitionId": "9qbX…",
"holding": "4T69…",
"decimals": 18
}
]
@@ -208,8 +207,9 @@ cp apps/amm/amm-tokens.json.example apps/amm/amm-tokens.json # then replace th
If `TOKENS_CONFIG` is unset, unreadable, or not a valid JSON array, the token
picker stays empty (a `qWarning` naming the exact cause is logged to stderr; no
swap can be started). `definitionId`/`holding` may be given as base58 (as the
wallet/runbook display them) or hex — the app normalizes both to hex.
swap can be started). `definitionId` may be given as base58 (as the
wallet/runbook displays it) or hex. A legacy `holding` value is accepted but is
not used for transaction account selection.
Full command with both variables set (absolute paths, from the repo root):
@@ -61,10 +61,17 @@ AmmActionCard {
})
readonly property var tokens: root.newPositionContext && root.newPositionContext.tokens
? root.newPositionContext.tokens : []
readonly property var programAccounts: root.newPositionContext
&& root.newPositionContext.programAccounts
? root.newPositionContext.programAccounts : []
readonly property var feeTiers: root.newPositionContext && root.newPositionContext.feeTiers
? root.newPositionContext.feeTiers : []
readonly property var tokenA: root.tokenById(root.selectedTokenAId)
readonly property var tokenB: root.tokenById(root.selectedTokenBId)
readonly property string selectedHoldingAId: tokenAInput.selectedHoldingId
readonly property string selectedHoldingBId: tokenBInput.selectedHoldingId
readonly property var holdingA: tokenAInput.selectedHolding
readonly property var holdingB: tokenBInput.selectedHolding
readonly property int decimalsA: 0
readonly property int decimalsB: 0
readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0
@@ -96,6 +103,9 @@ AmmActionCard {
&& !root.quoteStale
&& !root.submitting
&& !root.poolCreationPending
&& tokenAInput.holdingReady
&& tokenBInput.holdingReady
&& lpDestinationSelector.ready
signal quoteRequested(bool immediate, var quoteRequest)
signal confirmationRequested(var snapshot)
@@ -235,15 +245,18 @@ AmmActionCard {
theme: root.theme
text: root.amountA
label: qsTr("Token A amount")
balance: root.contextLoading ? "" : root.balanceText(root.tokenA, root.decimalsA)
balance: root.contextLoading ? "" : root.holdingBalanceText(
root.holdingA, root.decimalsA)
helperText: root.missingPool && !root.compact
? root.minimumAmountText("A") : ""
errorText: root.formErrorText()
invalid: root.fieldHasError("amountA")
|| root.fieldHasError("holdingAId")
readOnly: root.submitting || (!root.activePool && !root.missingPool)
showMaxButton: root.activePool
tokenData: root.tokenA.definitionId ? root.tokenA : null
tokens: root.tokens
programAccounts: root.programAccounts
selectedTokenId: root.selectedTokenAId
tokenInvalid: root.tokenHasError("A")
tokenSelectionEnabled: !root.contextLoading && !root.submitting
@@ -267,6 +280,10 @@ AmmActionCard {
onMaxClicked: root.useMaximum()
onTokenSelected: function(tokenId) { root.resolveToken("A", tokenId) }
onTokenEntered: function(value) { root.resolveToken("A", value) }
onHoldingSelectionChanged: function(holdingId) {
root.noteDraftChanged()
root.requestQuote(true)
}
}
AmmPairSeparator {
@@ -284,14 +301,17 @@ AmmActionCard {
theme: root.theme
text: root.amountB
label: qsTr("Token B amount")
balance: root.contextLoading ? "" : root.balanceText(root.tokenB, root.decimalsB)
balance: root.contextLoading ? "" : root.holdingBalanceText(
root.holdingB, root.decimalsB)
helperText: root.missingPool && !root.compact
? root.minimumAmountText("B") : ""
invalid: root.fieldHasError("amountB")
|| root.fieldHasError("holdingBId")
readOnly: root.submitting || (!root.activePool && !root.missingPool)
showMaxButton: root.activePool
tokenData: root.tokenB.definitionId ? root.tokenB : null
tokens: root.tokens
programAccounts: root.programAccounts
selectedTokenId: root.selectedTokenBId
tokenInvalid: root.tokenHasError("B")
tokenSelectionEnabled: !root.contextLoading && !root.submitting
@@ -315,6 +335,10 @@ AmmActionCard {
onMaxClicked: root.useMaximum()
onTokenSelected: function(tokenId) { root.resolveToken("B", tokenId) }
onTokenEntered: function(value) { root.resolveToken("B", value) }
onHoldingSelectionChanged: function(holdingId) {
root.noteDraftChanged()
root.requestQuote(true)
}
}
}
@@ -476,6 +500,44 @@ AmmActionCard {
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 6
visible: root.hasPair
Text {
text: qsTr("LP TokenHolding output")
color: root.theme.colors.textSecondary
font.pixelSize: 12
}
ProgramAccountSelector {
id: lpDestinationSelector
objectName: "lpTokenHoldingSelector"
Layout.fillWidth: true
sourceModel: root.programAccounts
accountType: "TokenHolding"
stateField: "definitionId"
stateValue: root.lpDefinitionIdHex()
selectionMode: ProgramAccountSelector.Output
createNewText: qsTr("Create new TokenHolding")
placeholderText: qsTr("Select LP destination")
criteriaPendingText: qsTr("Resolving LP token")
accessibleName: qsTr("LP TokenHolding destination")
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
onSelectionChanged: function(accountId, createNew) {
root.noteDraftChanged()
root.requestQuote(true)
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 9
@@ -826,6 +888,8 @@ AmmActionCard {
}
function swapTokens() {
var holdingAId = root.selectedHoldingAId
var holdingBId = root.selectedHoldingBId
var tokenId = root.selectedTokenAId
root.selectedTokenAId = root.selectedTokenBId
root.selectedTokenBId = tokenId
@@ -838,8 +902,12 @@ AmmActionCard {
var priceAmount = root.priceAmountA
root.priceAmountA = root.priceAmountB
root.priceAmountB = priceAmount
root.noteDraftChanged()
root.requestQuote(true)
Qt.callLater(function() {
tokenAInput.setHoldingSelection(holdingBId)
tokenBInput.setHoldingSelection(holdingAId)
root.noteDraftChanged()
root.requestQuote(true)
})
}
function resetPairDraft() {
@@ -1074,13 +1142,27 @@ AmmActionCard {
}
function pairRequest() {
return {
var request = {
"tokenAId": root.displayIsCanonical
? root.selectedTokenAId : root.selectedTokenBId,
"tokenBId": root.displayIsCanonical
? root.selectedTokenBId : root.selectedTokenAId,
"feeBps": root.selectedFeeBps
}
var holdingAId = root.displayIsCanonical
? root.selectedHoldingAId : root.selectedHoldingBId
var holdingBId = root.displayIsCanonical
? root.selectedHoldingBId : root.selectedHoldingAId
if (holdingAId.length > 0)
request.holdingAId = holdingAId
if (holdingBId.length > 0)
request.holdingBId = holdingBId
if (lpDestinationSelector.createNewSelected) {
request.createFreshLp = true
} else if (lpDestinationSelector.selectedAccountId.length > 0) {
request.lpHoldingId = lpDestinationSelector.selectedAccountId
}
return request
}
function requestQuote(immediate) {
@@ -1092,7 +1174,10 @@ AmmActionCard {
}
function probeRaw(token, decimals) {
var balance = String(token.balanceRaw || "0")
var holding = token.definitionId === root.tokenA.definitionId
? root.holdingA : root.holdingB
var balance = String(holding && holding.balanceRaw
? holding.balanceRaw : "0")
var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000")
if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0)
return balance
@@ -1132,6 +1217,12 @@ AmmActionCard {
return root.displayIsCanonical ? "amountA" : "amountB"
if (field === "amountBRaw")
return root.displayIsCanonical ? "amountB" : "amountA"
if (field === "holdingAId")
return root.displayIsCanonical ? "holdingAId" : "holdingBId"
if (field === "holdingBId")
return root.displayIsCanonical ? "holdingBId" : "holdingAId"
if (field === "lpHoldingId" || field === "createFreshLp")
return "lpHoldingId"
if (field === "initialPriceRealRaw")
return "initialPrice"
return field
@@ -1192,6 +1283,10 @@ AmmActionCard {
"invalid_amount_precision": qsTr("Token amounts must use whole raw units."),
"invalid_raw_amount": qsTr("Value is outside the supported range."),
"amount_exceeds_balance": qsTr("Amount exceeds the selected holding balance."),
"holding_selection_required": qsTr("Select a wallet TokenHolding for this token."),
"invalid_holding_selection": qsTr("Selected TokenHolding is unavailable."),
"lp_destination_required": qsTr("Select where LP tokens should be deposited."),
"invalid_lp_destination": qsTr("Selected LP TokenHolding is unavailable."),
"amount_too_low": qsTr("Value is too low for this pool."),
"invalid_token_id": qsTr("Enter a valid base58 TokenDefinition ID."),
"deposit_ratio_mismatch": qsTr("Deposit amounts must match the initial price."),
@@ -1261,8 +1356,10 @@ AmmActionCard {
var reserveB = root.poolReserve("B")
if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0")
return
var balanceA = String(root.tokenA.balanceRaw || "0")
var balanceB = String(root.tokenB.balanceRaw || "0")
var balanceA = String(root.holdingA && root.holdingA.balanceRaw
? root.holdingA.balanceRaw : "0")
var balanceB = String(root.holdingB && root.holdingB.balanceRaw
? root.holdingB.balanceRaw : "0")
var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB)
var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA
var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA)
@@ -1470,10 +1567,23 @@ AmmActionCard {
return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals)
}
function holdingBalanceText(holding, decimals) {
return holding ? AmountMath.formatRaw(
String(holding.balanceRaw || "0"), decimals) : ""
}
function tokenBalanceDetail(token) {
return qsTr("Available %1").arg(root.balanceText(token, 0))
}
function lpDefinitionIdHex() {
if (root.quoteMatchesPair())
return String(root.quotePayload.lpDefinitionIdHex || "")
if (root.quoteMatchesSelectedPair(root.activePoolQuote))
return String(root.activePoolQuote.lpDefinitionIdHex || "")
return ""
}
function shortId(value) {
var text = String(value || "")
return text.length > 14 ? text.slice(0, 7) + "…" + text.slice(-5) : text
@@ -1,6 +1,9 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Logos.Wallet
import "../shared"
@@ -13,6 +16,7 @@ AmmTokenAmountSurface {
property bool showMaxButton: true
property var tokenData: null
property var tokens: []
property var programAccounts: []
property string selectedTokenId: ""
property bool tokenInvalid: false
property bool tokenSelectionEnabled: true
@@ -25,19 +29,26 @@ AmmTokenAmountSurface {
property alias popup: tokenModal
property alias query: tokenModal.searchText
readonly property var rows: tokenModal.rows
property string selectedHoldingId: ""
property bool holdingReady: false
property bool hasHoldingFunds: false
property var selectedHolding: null
property real accessoryContentHeight: 40
signal editingChanged(string value)
signal editingCommitted(string value)
signal maxClicked
signal tokenSelected(string tokenId)
signal tokenEntered(string value)
signal holdingSelectionChanged(string holdingId)
signal holdingSelectionRequested(string holdingId)
amount: root.text
supportingText: root.helperText
supportingActionText: root.showMaxButton ? qsTr("MAX") : ""
accessory: tokenActions
accessoryWidth: width < 360 ? 132 : 180
accessoryHeight: root.balance.length > 0 ? 58 : 40
accessoryHeight: root.accessoryContentHeight
onAmountEdited: function(value) {
root.pendingValue = value
@@ -68,17 +79,87 @@ AmmTokenAmountSurface {
Component {
id: tokenActions
AmmTokenAccessory {
theme: root.theme
enabled: root.tokenSelectionEnabled
invalid: root.tokenInvalid
hasToken: root.tokenData !== null
tokenColor: root.tokenColor(root.tokenData)
tokenLetter: root.tokenLetter(root.tokenData)
tokenText: root.tokenText(root.tokenData)
balance: root.balance
accessibleName: qsTr("Select %1").arg(root.label)
onClicked: tokenModal.open()
ColumnLayout {
id: tokenActionLayout
spacing: 4
Binding {
target: root
property: "accessoryContentHeight"
value: tokenActionLayout.implicitHeight
}
Binding {
target: root
property: "selectedHoldingId"
value: holdingPicker.selectedAccountId
}
Binding {
target: root
property: "holdingReady"
value: holdingPicker.ready
}
Binding {
target: root
property: "hasHoldingFunds"
value: holdingPicker.hasFunds
}
Binding {
target: root
property: "selectedHolding"
value: holdingPicker.selectedAccount
}
Connections {
target: root
function onHoldingSelectionRequested(accountId) {
holdingPicker.setSelection(accountId, false)
holdingPicker.reconcileSelection()
}
}
AmmTokenAccessory {
Layout.fillWidth: true
theme: root.theme
enabled: root.tokenSelectionEnabled
invalid: root.tokenInvalid
hasToken: root.tokenData !== null
tokenColor: root.tokenColor(root.tokenData)
tokenLetter: root.tokenLetter(root.tokenData)
tokenText: root.tokenText(root.tokenData)
balance: root.balance
accessibleName: qsTr("Select %1").arg(root.label)
onClicked: tokenModal.open()
}
ProgramAccountSelector {
id: holdingPicker
Layout.fillWidth: true
sourceModel: root.programAccounts
accountType: "TokenHolding"
stateField: "definitionId"
stateValue: root.tokenData
? String(root.tokenData.definitionIdHex
|| root.tokenData.definitionId || "") : ""
selectionMode: ProgramAccountSelector.Input
placeholderText: qsTr("Select source")
accessibleName: qsTr("Source TokenHolding for %1").arg(root.label)
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
onSelectionChanged: function(accountId, createNew) {
root.holdingSelectionChanged(accountId)
}
}
}
}
@@ -105,6 +186,10 @@ AmmTokenAmountSurface {
tokenModal.acceptInput(value)
}
function setHoldingSelection(accountId) {
root.holdingSelectionRequested(accountId)
}
function commitPendingEdit() {
if (!root.editPending)
return
+59 -11
View File
@@ -1,6 +1,7 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Logos.Wallet
import "../shared"
import "../../state"
@@ -14,6 +15,7 @@ Rectangle {
property var theme
property var tokens: []
property var programAccounts: []
// Real backend replica (logos.module("amm_ui")), wired from SwapPage.
property var backend: null
@@ -389,10 +391,16 @@ Rectangle {
&& root.poolResolved && root.poolExists
&& !outputExceedsLiquidity && !root.swapInProgress
&& !root.quoteLoading && root.walletOpen
&& sellAmountInput.holdingReady
&& buyAmountInput.holdingReady
readonly property string submitButtonText: {
if (!tokensSelected) return qsTr("Select tokens")
if (root.swapInProgress) return qsTr("Submitting…")
if (root.sellToken && !sellAmountInput.hasHoldingFunds) return qsTr("No funds")
if (root.sellToken && !sellAmountInput.holdingReady) return qsTr("Select source holding")
if (root.buyToken && !buyAmountInput.holdingReady) return qsTr("Select destination")
if (root.backend && !root.backend.isWalletOpen) return qsTr("Connect wallet")
if (!hasAmount) return qsTr("Enter an amount")
if (root.poolLoading || !root.poolResolved) return qsTr("Resolving pool…")
if (!root.poolExists) return qsTr("No pool / no liquidity")
@@ -442,7 +450,10 @@ Rectangle {
"priceImpactPercentValue": priceImpactPercent,
"slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent),
"swapMode": isExactIn ? "swap-exact-input" : "swap-exact-output",
"swapModeText": swapModeText
"swapModeText": swapModeText,
"inputHoldingId": sellAmountInput.selectedHoldingId,
"outputHoldingId": buyAmountInput.selectedHoldingId,
"createOutputHolding": buyAmountInput.createNewHolding
}
}
@@ -453,15 +464,36 @@ Rectangle {
if (!root.backend || !root.canSubmit)
return
root.swapInProgress = true
root.swapError = ""
// Max u64 sentinel: "ignore deadline", per AmmUiBackend.rep.
var deadline = "18446744073709551615"
var inDef = root.sellToken.definitionId
var outDef = root.buyToken.definitionId
var inHolding = root.sellToken.holding
var outHolding = root.buyToken.holding
var inHolding = sellAmountInput.selectedHoldingId
var outHolding = buyAmountInput.selectedHoldingId
if (buyAmountInput.createNewHolding) {
root.swapInProgress = true
root.swapError = ""
logos.watch(root.backend.createAccountPublic(),
function(accountId) {
if (!accountId) {
root.failSwap(qsTr("Could not create a destination TokenHolding."))
return
}
root.submitSwap(inDef, outDef, inHolding, String(accountId), deadline)
},
function(error) {
root.failSwap(qsTr("Could not create a destination TokenHolding: %1").arg(error))
})
return
}
root.submitSwap(inDef, outDef, inHolding, outHolding, deadline)
}
function submitSwap(inDef, outDef, inHolding, outHolding, deadline) {
root.swapInProgress = true
root.swapError = ""
// The on-chain guard is the quote's exact-integer bound: the exact-input
// floor (minReceivedRaw) or the exact-output ceiling (maxInRaw). The typed
@@ -483,19 +515,27 @@ Rectangle {
})
root.resetAmounts()
resolveDebounce.restart()
logos.watch(root.backend.refreshNewPositionContext({
"refreshWalletAccounts": true
}), function() {}, function(error) {
console.warn("wallet holding refresh error:", error)
})
} else {
root.swapError = qsTr("Swap failed (empty response from sequencer).")
root.swapFailed(root.swapError)
root.failSwap(qsTr("Swap failed (empty response from sequencer)."))
}
},
function (error) {
console.warn("swap error:", error)
root.swapInProgress = false
root.swapError = qsTr("Swap error: %1").arg(error)
root.swapFailed(root.swapError)
root.failSwap(qsTr("Swap error: %1").arg(error))
})
}
function failSwap(message) {
root.swapInProgress = false
root.swapError = message
root.swapFailed(message)
}
radius: 24
color: theme.colors.cardBg
border.color: theme.colors.border
@@ -514,6 +554,8 @@ Rectangle {
spacing: 0
TokenInput {
id: sellAmountInput
Layout.fillWidth: true
theme: root.theme
label: "Sell"
@@ -521,6 +563,8 @@ Rectangle {
buttonObjectName: "swapSellTokenButton"
amount: root.sellDisplay
token: root.sellToken
programAccounts: root.programAccounts
holdingSelectionMode: ProgramAccountSelector.Input
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 +618,8 @@ Rectangle {
}
TokenInput {
id: buyAmountInput
Layout.fillWidth: true
theme: root.theme
label: "Buy"
@@ -581,6 +627,8 @@ Rectangle {
buttonObjectName: "swapBuyTokenButton"
amount: root.buyDisplay
token: root.buyToken
programAccounts: root.programAccounts
holdingSelectionMode: ProgramAccountSelector.Output
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.
+57 -13
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,8 @@ Rectangle {
property string amount: ""
property string usdValue: ""
property var token: null
property var programAccounts: []
property int holdingSelectionMode: ProgramAccountSelector.Input
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
@@ -25,6 +28,13 @@ Rectangle {
signal tokenClicked()
signal inputEdited(string newValue)
signal holdingSelectionChanged(string accountId, bool createNew)
property alias selectedHoldingId: holdingSelector.selectedAccountId
property alias createNewHolding: holdingSelector.createNewSelected
readonly property bool holdingReady: holdingSelector.ready
readonly property bool hasHoldingFunds: holdingSelector.hasFunds
readonly property var selectedHolding: holdingSelector.selectedAccount
Binding {
target: tiInput
@@ -104,13 +114,17 @@ Rectangle {
}
}
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 } }
ColumnLayout {
spacing: 6
Rectangle {
id: tokenButton
Layout.alignment: Qt.AlignRight
Layout.preferredHeight: 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
@@ -144,12 +158,42 @@ Rectangle {
}
}
MouseArea {
id: tokenBtnHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.tokenClicked()
MouseArea {
id: tokenBtnHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.tokenClicked()
}
}
ProgramAccountSelector {
id: holdingSelector
Layout.alignment: Qt.AlignRight
Layout.preferredWidth: 190
sourceModel: root.programAccounts
accountType: "TokenHolding"
stateField: "definitionId"
stateValue: root.token
? String(root.token.definitionIdHex
|| root.token.definitionId || "") : ""
selectionMode: root.holdingSelectionMode
createNewText: qsTr("Create new TokenHolding")
placeholderText: root.holdingSelectionMode === ProgramAccountSelector.Input
? qsTr("Select source") : qsTr("Select destination")
accessibleName: root.holdingSelectionMode === ProgramAccountSelector.Input
? qsTr("Source TokenHolding for %1").arg(root.label)
: qsTr("Destination TokenHolding for %1").arg(root.label)
backgroundColor: theme.colors.panelBg
hoverColor: theme.colors.panelHoverBg
textColor: theme.colors.textPrimary
secondaryTextColor: theme.colors.textSecondary
borderColor: theme.colors.borderStrong
focusColor: theme.colors.ctaBg
onSelectionChanged: function(accountId, createNew) {
root.holdingSelectionChanged(accountId, createNew)
}
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
// Shared derivation helpers for a token's display avatar (color + letter).
// The real token config (see AmmUiBackend::tokenList / TOKENS_CONFIG) only
// carries symbol/name/definitionId/holding/decimals — no color/letter — so
// carries symbol/name/definitionId/decimals — no color/letter — so
// every place that used to read token.color/token.letter derives them here
// instead, deterministically from the token's symbol.
+5
View File
@@ -16,6 +16,10 @@ Item {
// reads the TOKENS_CONFIG JSON file — see apps/amm/README.md). Empty
// until the backend is ready and the call resolves.
property var tokens: []
readonly property var programAccounts: root.backend
&& root.backend.newPositionContext
&& root.backend.newPositionContext.programAccounts
? root.backend.newPositionContext.programAccounts : []
onBackendChanged: {
if (root.backend) {
@@ -104,6 +108,7 @@ Item {
Layout.alignment: Qt.AlignHCenter
theme: pageTheme
tokens: root.tokens
programAccounts: root.programAccounts
backend: root.backend
width: Math.min(480, root.width - 32)
+3 -3
View File
@@ -88,8 +88,8 @@ class AmmUiBackend
// Same return contract as swapExactInput (tx hash, empty string on failure).
SLOT(QString swapExactOutput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountOutDecimal, QString maxInDecimal, QString deadlineDecimal))
// Reads the token list config at TOKENS_CONFIG (absolute path, JSON array
// of { symbol, name, definitionId, holding, decimals }) and returns it as
// a QVariantList of QVariantMap entries. Returns an empty list if
// TOKENS_CONFIG is unset/unreadable/invalid.
// of { symbol, name, definitionId, decimals }). A legacy holding field is
// accepted but transaction inputs come from wallet-owned TokenHoldings.
// Returns an empty list if TOKENS_CONFIG is unset/unreadable/invalid.
SLOT(QVariantList tokenList())
}
@@ -13,6 +13,8 @@ TestCase {
readonly property string tokenLow: "22222222222222222222222222222222"
readonly property string tokenHigh: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
readonly property string tokenThird: "33333333333333333333333333333333"
readonly property string holdingLow: "holding-low"
readonly property string holdingHigh: "holding-high"
readonly property string submittedTransactionId:
"1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"
@@ -43,6 +45,7 @@ TestCase {
"tokens": [
{
"definitionId": tokenLow,
"definitionIdHex": tokenLow,
"name": "Low",
"totalSupplyRaw": "1000000",
"balanceRaw": "1000",
@@ -50,11 +53,16 @@ TestCase {
},
{
"definitionId": tokenHigh,
"definitionIdHex": tokenHigh,
"name": "High",
"totalSupplyRaw": "1000000000000",
"balanceRaw": "5000000000",
"selectable": true
}
],
"programAccounts": [
programAccount(holdingLow, tokenLow, "1000"),
programAccount(holdingHigh, tokenHigh, "5000000000")
]
}
}
@@ -65,6 +73,7 @@ TestCase {
"tokens": [
{
"definitionId": tokenLow,
"definitionIdHex": tokenLow,
"name": "Sir Mints-a-Lot",
"totalSupplyRaw": "1000000000000",
"balanceRaw": "1000000000",
@@ -72,15 +81,29 @@ TestCase {
},
{
"definitionId": tokenHigh,
"definitionIdHex": tokenHigh,
"name": "Aurora",
"totalSupplyRaw": "1000000000000",
"balanceRaw": "1000000000",
"selectable": true
}
],
"programAccounts": [
programAccount(holdingLow, tokenLow, "1000000000"),
programAccount(holdingHigh, tokenHigh, "1000000000")
]
}
}
function programAccount(accountId, definitionId, balanceRaw) {
return {
"accountId": accountId,
"accountType": "TokenHolding",
"definitionId": definitionId,
"balanceRaw": balanceRaw
}
}
function flowState(quote) {
return {
"quote": quote || ({}),
@@ -107,6 +130,8 @@ TestCase {
form.selectToken("B", tokenHigh)
compare(form.selectedTokenAId, tokenLow)
compare(form.selectedTokenBId, tokenHigh)
tryCompare(form, "selectedHoldingAId", holdingLow)
tryCompare(form, "selectedHoldingBId", holdingHigh)
return form
}
@@ -124,14 +149,20 @@ TestCase {
verify(built.ok)
compare(built.request.tokenAId, tokenHigh)
compare(built.request.tokenBId, tokenLow)
compare(built.request.holdingAId, holdingHigh)
compare(built.request.holdingBId, holdingLow)
form.swapTokens()
tryCompare(form, "selectedHoldingAId", holdingHigh)
tryCompare(form, "selectedHoldingBId", holdingLow)
compare(form.selectedTokenAId, tokenHigh)
compare(form.selectedTokenBId, tokenLow)
built = form.buildQuoteRequest()
verify(built.ok)
compare(built.request.tokenAId, tokenHigh)
compare(built.request.tokenBId, tokenLow)
compare(built.request.holdingAId, holdingHigh)
compare(built.request.holdingBId, holdingLow)
}
function test_tokenAmountsUseRawUnits() {
@@ -95,6 +95,61 @@ TestCase {
compare(input.accessoryWidth, 180)
}
function test_singleMatchingHoldingAutoSelects() {
var input = createTemporaryObject(inputComponent, testCase, {
"tokenData": {
"definitionId": enabledId,
"definitionIdHex": enabledId,
"name": "Enabled"
},
"programAccounts": [{
"accountId": "holding-enabled",
"accountType": "TokenHolding",
"definitionId": enabledId,
"balanceRaw": "42"
}]
})
verify(input)
tryCompare(input, "selectedHoldingId", "holding-enabled")
compare(input.holdingReady, true)
compare(input.hasHoldingFunds, true)
compare(input.selectedHolding.balanceRaw, "42")
}
function test_multipleMatchingHoldingsRequireSelection() {
var input = createTemporaryObject(inputComponent, testCase, {
"tokenData": {
"definitionId": enabledId,
"definitionIdHex": enabledId,
"name": "Enabled"
},
"programAccounts": [
{
"accountId": "holding-a",
"accountType": "TokenHolding",
"definitionId": enabledId,
"balanceRaw": "42"
},
{
"accountId": "holding-b",
"accountType": "TokenHolding",
"definitionId": enabledId,
"balanceRaw": "21"
}
]
})
verify(input)
tryCompare(input, "hasHoldingFunds", true)
compare(input.holdingReady, false)
compare(input.selectedHoldingId, "")
input.setHoldingSelection("holding-b")
compare(input.selectedHoldingId, "holding-b")
compare(input.holdingReady, true)
}
function test_disabledTokenIsRejectedByTypedInput() {
var input = createTemporaryObject(inputComponent, testCase)
verify(input)
+1
View File
@@ -67,6 +67,7 @@ if(LOGOS_WALLET_BUILD_QML)
)
set(wallet_public_qml
qml/WalletControl.qml
qml/ProgramAccountSelector.qml
qml/TransactionConfirmationDialog.qml
qml/SubmittedTransaction.qml
)
@@ -0,0 +1,361 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls.Basic
Item {
id: root
enum SelectionMode {
Input,
Output
}
property var sourceModel: []
property string accountType: ""
property string stateField: ""
property var stateValue: ""
property int selectionMode: ProgramAccountSelector.Input
property string selectedAccountId: ""
property bool createNewSelected: false
property string createNewText: qsTr("Create new account")
property string emptyInputText: qsTr("No funds")
property string placeholderText: qsTr("Select account")
property string criteriaPendingText: qsTr("Select token first")
property string accessibleName: qsTr("Program account")
property color backgroundColor: "#27272a"
property color hoverColor: "#3f3f46"
property color textColor: "#f4f4f5"
property color secondaryTextColor: "#a1a1aa"
property color borderColor: "#52525b"
property color focusColor: "#f26a21"
property int modelRevision: 0
readonly property bool criteriaReady: root.accountType.length > 0
&& (root.stateField.length === 0
|| root.scalarText(root.stateValue).length > 0)
readonly property var matchingAccounts: root.filteredAccounts()
readonly property var choices: root.choiceRows()
readonly property bool hasFunds: root.matchingAccounts.length > 0
readonly property bool selectionValid: root.accountById(root.selectedAccountId) !== null
readonly property bool ready: root.criteriaReady
&& (root.selectionValid
|| (root.selectionMode === ProgramAccountSelector.Output
&& root.createNewSelected))
readonly property var selectedAccount: root.accountById(root.selectedAccountId)
readonly property string selectedBalanceRaw: root.selectedAccount
? String(root.valueFor(
root.selectedAccount,
"balanceRaw") || "0")
: "0"
readonly property bool showCombo: root.selectionMode === ProgramAccountSelector.Output
|| (root.criteriaReady
&& root.matchingAccounts.length > 1)
readonly property bool showEmptyInput: root.selectionMode === ProgramAccountSelector.Input
&& root.criteriaReady
&& root.matchingAccounts.length === 0
signal selectionChanged(string accountId, bool createNew)
implicitWidth: 220
implicitHeight: root.showCombo ? 34 : root.showEmptyInput ? 20 : 0
visible: implicitHeight > 0
Instantiator {
id: rows
model: root.sourceModel
delegate: QtObject {
required property var model
required property var modelData
readonly property var accountRow: {
if (modelData !== null && typeof modelData === "object") {
return modelData
}
if (model === null || typeof model !== "object")
return ({})
return {
"accountId": model.accountId,
"address": model.address,
"displayAddress": model.displayAddress,
"accountType": model.accountType,
"definitionId": model.definitionId,
"balanceRaw": model.balanceRaw,
"state": model.state
}
}
}
onObjectAdded: function(index, object) {
++root.modelRevision
Qt.callLater(root.reconcileSelection)
}
onObjectRemoved: function(index, object) {
++root.modelRevision
Qt.callLater(root.reconcileSelection)
}
}
onSourceModelChanged: Qt.callLater(root.reconcileSelection)
onAccountTypeChanged: Qt.callLater(root.reconcileSelection)
onStateFieldChanged: Qt.callLater(root.reconcileSelection)
onStateValueChanged: Qt.callLater(root.reconcileSelection)
onSelectionModeChanged: Qt.callLater(root.reconcileSelection)
Component.onCompleted: Qt.callLater(root.reconcileSelection)
Text {
anchors.fill: parent
visible: root.showEmptyInput
text: root.emptyInputText
color: root.secondaryTextColor
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
Accessible.role: Accessible.StaticText
Accessible.name: text
}
ComboBox {
id: accountCombo
objectName: "programAccountComboBox"
anchors.fill: parent
visible: root.showCombo
enabled: root.enabled && root.criteriaReady && root.choices.length > 0
model: root.choices
currentIndex: root.choiceIndex()
displayText: root.displayLabel()
leftPadding: 10
rightPadding: 28
topPadding: 0
bottomPadding: 0
hoverEnabled: true
activeFocusOnTab: true
focusPolicy: Qt.StrongFocus
Accessible.name: root.accessibleName
contentItem: Text {
leftPadding: accountCombo.leftPadding
rightPadding: accountCombo.rightPadding
text: accountCombo.displayText
color: accountCombo.enabled ? root.textColor : root.secondaryTextColor
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
elide: Text.ElideMiddle
}
indicator: Text {
x: accountCombo.width - width - 10
y: Math.round((accountCombo.height - height) / 2)
text: "\u25BE"
color: accountCombo.enabled ? root.secondaryTextColor : root.borderColor
font.pixelSize: 10
}
background: Rectangle {
radius: 7
color: !accountCombo.enabled
? root.backgroundColor
: accountCombo.down || accountCombo.hovered
? root.hoverColor : root.backgroundColor
border.color: accountCombo.activeFocus ? root.focusColor : root.borderColor
border.width: 1
}
delegate: ItemDelegate {
id: optionDelegate
required property int index
required property var modelData
width: ListView.view ? ListView.view.width : accountCombo.width
height: 34
hoverEnabled: true
highlighted: accountCombo.highlightedIndex === optionDelegate.index
contentItem: Text {
leftPadding: 8
rightPadding: 8
text: root.labelFor(optionDelegate.modelData)
color: root.textColor
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
elide: Text.ElideMiddle
}
background: Rectangle {
radius: 5
color: optionDelegate.highlighted || optionDelegate.hovered
? root.hoverColor : "transparent"
}
}
popup: Popup {
y: accountCombo.height + 4
width: accountCombo.width
implicitHeight: Math.min(contentItem.implicitHeight + topPadding + bottomPadding,
204)
padding: 4
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: accountCombo.delegateModel
currentIndex: accountCombo.highlightedIndex
highlightMoveDuration: 0
ScrollIndicator.vertical: ScrollIndicator { }
}
background: Rectangle {
radius: 7
color: root.backgroundColor
border.color: root.borderColor
border.width: 1
}
}
onActivated: function(index) {
const choice = root.choices[index]
if (!choice)
return
root.setSelection(choice.createNew === true
? "" : root.accountIdFor(choice),
choice.createNew === true)
}
}
function filteredAccounts() {
root.modelRevision
if (!root.criteriaReady)
return []
const result = []
for (let index = 0; index < rows.count; ++index) {
const object = rows.objectAt(index)
const row = object ? root.valueFor(object, "accountRow") : null
if (!row)
continue
const type = String(root.valueFor(row, "accountType")
|| root.valueFor(row, "typeName")
|| root.valueFor(row, "programType") || "")
if (type !== root.accountType)
continue
if (root.stateField.length > 0
&& root.scalarText(root.valueFor(row, root.stateField))
!== root.scalarText(root.stateValue)) {
continue
}
if (root.accountIdFor(row).length > 0)
result.push(row)
}
return result
}
function valueFor(row, field) {
if (!row)
return undefined
if (row[field] !== undefined)
return row[field]
if (row.state && row.state[field] !== undefined)
return row.state[field]
if (row.fields && row.fields[field] !== undefined)
return row.fields[field]
return undefined
}
function scalarText(value) {
return value === undefined || value === null ? "" : String(value)
}
function accountIdFor(row) {
return String(root.valueFor(row, "accountId")
|| root.valueFor(row, "displayAddress")
|| root.valueFor(row, "address")
|| root.valueFor(row, "holdingId") || "")
}
function accountById(accountId) {
const value = String(accountId || "")
if (value.length === 0)
return null
for (let index = 0; index < root.matchingAccounts.length; ++index) {
if (root.accountIdFor(root.matchingAccounts[index]) === value)
return root.matchingAccounts[index]
}
return null
}
function choiceRows() {
const result = root.matchingAccounts.slice(0)
if (root.selectionMode === ProgramAccountSelector.Output)
result.push({ "createNew": true })
return result
}
function choiceIndex() {
if (root.createNewSelected)
return root.choices.length - 1
for (let index = 0; index < root.choices.length; ++index) {
if (root.accountIdFor(root.choices[index]) === root.selectedAccountId)
return index
}
return -1
}
function displayLabel() {
const index = root.choiceIndex()
if (index >= 0)
return root.labelFor(root.choices[index])
if (!root.criteriaReady)
return root.criteriaPendingText
return root.placeholderText
}
function labelFor(row) {
if (row && row.createNew === true)
return root.createNewText
const id = root.accountIdFor(row)
const balance = String(root.valueFor(row, "balanceRaw") || "")
return balance.length > 0
? qsTr("%1 · %2").arg(root.shortId(id)).arg(balance)
: root.shortId(id)
}
function shortId(value) {
const text = String(value || "")
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
}
function setSelection(accountId, createNew) {
const nextId = String(accountId || "")
const nextCreate = createNew === true
if (root.selectedAccountId === nextId && root.createNewSelected === nextCreate)
return
root.selectedAccountId = nextId
root.createNewSelected = nextCreate
root.selectionChanged(nextId, nextCreate)
}
function reconcileSelection() {
if (!root.criteriaReady) {
root.setSelection("", false)
return
}
if (root.selectionValid)
return
if (root.selectionMode === ProgramAccountSelector.Input) {
root.setSelection(root.matchingAccounts.length === 1
? root.accountIdFor(root.matchingAccounts[0]) : "",
false)
return
}
if (root.createNewSelected)
return
if (root.matchingAccounts.length === 0) {
root.setSelection("", true)
} else if (root.matchingAccounts.length === 1) {
root.setSelection(root.accountIdFor(root.matchingAccounts[0]), false)
} else {
root.setSelection("", false)
}
}
}
@@ -0,0 +1,152 @@
import QtQuick
import QtTest
import Logos.Wallet as Wallet
Item {
id: root
width: 480
height: 320
readonly property var holdingA: ({
"accountId": "holding-a",
"accountType": "TokenHolding",
"definitionId": "token-a",
"balanceRaw": "120"
})
readonly property var holdingB: ({
"accountId": "holding-b",
"accountType": "TokenHolding",
"state": {
"definitionId": "token-a",
"balanceRaw": "80"
}
})
readonly property var otherToken: ({
"accountId": "holding-c",
"accountType": "TokenHolding",
"definitionId": "token-b",
"balanceRaw": "50"
})
readonly property var otherType: ({
"accountId": "pool-a",
"accountType": "Pool",
"definitionId": "token-a"
})
Component {
id: selectorComponent
Wallet.ProgramAccountSelector {
width: 260
accountType: "TokenHolding"
stateField: "definitionId"
stateValue: "token-a"
}
}
TestCase {
name: "ProgramAccountSelector"
when: windowShown
function test_inputNoFunds() {
const selector = createTemporaryObject(selectorComponent, root, {
"sourceModel": [root.otherToken, root.otherType],
"selectionMode": Wallet.ProgramAccountSelector.Input
})
verify(!!selector, "Component exists")
tryCompare(selector, "showEmptyInput", true)
compare(selector.showCombo, false)
compare(selector.hasFunds, false)
compare(selector.ready, false)
compare(selector.selectedAccountId, "")
}
function test_inputSingleHoldingAutoSelectsWithoutCombo() {
const selector = createTemporaryObject(selectorComponent, root, {
"sourceModel": [root.holdingA, root.otherToken],
"selectionMode": Wallet.ProgramAccountSelector.Input
})
verify(!!selector, "Component exists")
tryCompare(selector, "selectedAccountId", "holding-a")
compare(selector.showCombo, false)
compare(selector.hasFunds, true)
compare(selector.ready, true)
compare(selector.selectedBalanceRaw, "120")
}
function test_inputMultipleHoldingsRequiresSelection() {
const selector = createTemporaryObject(selectorComponent, root, {
"sourceModel": [root.holdingA, root.holdingB],
"selectionMode": Wallet.ProgramAccountSelector.Input
})
verify(!!selector, "Component exists")
tryCompare(selector, "showCombo", true)
compare(selector.matchingAccounts.length, 2)
compare(selector.ready, false)
selector.setSelection("holding-b", false)
compare(selector.selectedAccountId, "holding-b")
compare(selector.selectedBalanceRaw, "80")
compare(selector.ready, true)
}
function test_outputNoHoldingSelectsCreateNew() {
const selector = createTemporaryObject(selectorComponent, root, {
"sourceModel": [],
"selectionMode": Wallet.ProgramAccountSelector.Output
})
verify(!!selector, "Component exists")
tryCompare(selector, "createNewSelected", true)
compare(selector.showCombo, true)
compare(selector.choices.length, 1)
compare(selector.ready, true)
}
function test_outputSingleHoldingOffersExistingAndCreateNew() {
const selector = createTemporaryObject(selectorComponent, root, {
"sourceModel": [root.holdingA],
"selectionMode": Wallet.ProgramAccountSelector.Output
})
verify(!!selector, "Component exists")
tryCompare(selector, "selectedAccountId", "holding-a")
compare(selector.choices.length, 2)
compare(selector.createNewSelected, false)
compare(selector.ready, true)
selector.setSelection("", true)
compare(selector.selectedAccountId, "")
compare(selector.createNewSelected, true)
compare(selector.ready, true)
}
function test_outputMultipleHoldingsRequiresDestination() {
const selector = createTemporaryObject(selectorComponent, root, {
"sourceModel": [root.holdingA, root.holdingB],
"selectionMode": Wallet.ProgramAccountSelector.Output
})
verify(!!selector, "Component exists")
tryCompare(selector, "showCombo", true)
compare(selector.choices.length, 3)
compare(selector.selectedAccountId, "")
compare(selector.createNewSelected, false)
compare(selector.ready, false)
}
function test_matchesNumericZeroState() {
const selector = createTemporaryObject(selectorComponent, root, {
"sourceModel": [{
"accountId": "zero-state",
"accountType": "TokenHolding",
"state": { "version": 0 }
}],
"stateField": "version",
"stateValue": 0,
"selectionMode": Wallet.ProgramAccountSelector.Input
})
verify(!!selector, "Component exists")
tryCompare(selector, "selectedAccountId", "zero-state")
compare(selector.ready, true)
}
}
}