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)
}
}
}
+9 -4
View File
@@ -27,6 +27,7 @@ pub(super) fn missing_account_plan(
])?;
append_holding_source(&mut sources, "holding_a", holdings.token_a);
append_holding_source(&mut sources, "holding_b", holdings.token_b);
append_holding_source(&mut sources, "holding_lp", holdings.lp);
Ok(AccountPlan {
rows: vec![
AccountPlanRow::new(
@@ -95,11 +96,15 @@ pub(super) fn missing_account_plan(
),
AccountPlanRow::new(
"user_holding_lp",
None,
holdings.lp.map(|value| value.id),
Some(pair.token_program),
"create",
true,
true,
if holdings.lp.is_some() {
"update"
} else {
"create"
},
holdings.lp.is_none(),
holdings.lp.is_none(),
),
AccountPlanRow::new(
"current_tick",
+42 -9
View File
@@ -9,7 +9,7 @@ use token_core::TokenDefinition;
use super::{
config::load_config,
holding::{select_holding, wallet_holdings, SelectedHolding},
holding::{holding_options, wallet_holdings, SelectedHolding},
quote_error::issue,
ContextRequest, TokenIdsRequest,
};
@@ -59,6 +59,26 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
};
let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id);
let mut program_accounts = holdings.clone();
program_accounts.sort_by_key(|holding| holding.id);
let program_accounts = program_accounts
.into_iter()
.map(|holding| {
json!({
"accountId": holding.id.to_string(),
"address": account_id_hex(holding.id),
"displayAddress": holding.id.to_string(),
"accountType": "TokenHolding",
"definitionId": account_id_hex(holding.definition_id),
"definitionDisplayId": holding.definition_id.to_string(),
"balanceRaw": holding.balance.to_string(),
"state": {
"definitionId": account_id_hex(holding.definition_id),
"balanceRaw": holding.balance.to_string(),
},
})
})
.collect::<Vec<_>>();
let source_map = token_sources(&request, &holdings);
let mut rows = Vec::new();
let mut warnings = Vec::new();
@@ -85,9 +105,13 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
}
};
let selected = select_holding(&holdings, token_id);
let mut row = json!({
let options = holding_options(&holdings, token_id);
let total_balance = options.iter().fold(0_u128, |total, holding| {
total.saturating_add(holding.balance)
});
let row = json!({
"definitionId": token_id.to_string(),
"definitionIdHex": account_id_hex(token_id),
"name": name,
"metadataId": metadata_id.map(|id| id.to_string()),
"totalSupplyRaw": total_supply.to_string(),
@@ -98,17 +122,23 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
"status": "available",
"code": "available",
"sources": sources,
"balanceRaw": total_balance.to_string(),
"holdings": options.into_iter().map(|holding| json!({
"holdingId": holding.id.to_string(),
"address": account_id_hex(holding.id),
"balanceRaw": holding.balance.to_string(),
})).collect::<Vec<_>>(),
});
if let Some(selected) = selected {
row["holdingId"] = json!(selected.id.to_string());
row["balanceRaw"] = json!(selected.balance.to_string());
}
rows.push(row);
}
rows.sort_by(|left, right| {
let left_holding = left.get("holdingId").is_some();
let right_holding = right.get("holdingId").is_some();
let left_holding = left["holdings"]
.as_array()
.is_some_and(|rows| !rows.is_empty());
let right_holding = right["holdings"]
.as_array()
.is_some_and(|rows| !rows.is_empty());
right_holding.cmp(&left_holding).then_with(|| {
left["definitionId"]
.as_str()
@@ -128,6 +158,7 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
"twapOracle": program_id_base58(config.twap_oracle_program_id),
},
"tokens": rows,
"programAccounts": program_accounts,
"feeTiers": fee_tiers(),
"warnings": warnings,
}))
@@ -141,6 +172,7 @@ fn context_error(request: &ContextRequest, code: &str) -> Value {
"networkFingerprint": request.network_fingerprint,
"walletAvailable": request.wallet_available,
"tokens": [],
"programAccounts": [],
"feeTiers": fee_tiers(),
"warnings": [],
})
@@ -193,6 +225,7 @@ fn token_sources(
fn unavailable_token_row(token_id: AccountId, sources: Vec<String>, code: &str) -> Value {
json!({
"definitionId": token_id.to_string(),
"definitionIdHex": account_id_hex(token_id),
"name": "",
"metadataId": Value::Null,
"totalSupplyRaw": "0",
+24 -8
View File
@@ -4,7 +4,7 @@ use nssa_core::{
};
use token_core::TokenHolding;
use crate::account::{decode_account, AccountRead};
use crate::account::{account_id_from_hex, decode_account, parse_base58_id, AccountRead};
#[derive(Clone)]
pub(super) struct SelectedHolding {
@@ -51,14 +51,30 @@ pub(super) fn decode_fungible_holding(
pub(super) fn select_holding(
holdings: &[SelectedHolding],
definition_id: AccountId,
requested_id: Option<&str>,
) -> Option<SelectedHolding> {
holdings
let options = holding_options(holdings, definition_id);
let Some(requested_id) = requested_id else {
return (options.len() == 1).then(|| options[0].clone());
};
let requested_id = account_id_from_hex(requested_id, "holding id")
.or_else(|_| parse_base58_id(requested_id, "holding id"))
.ok()?;
options
.iter()
.filter(|holding| holding.definition_id == definition_id)
.max_by(|left, right| {
left.balance
.cmp(&right.balance)
.then_with(|| right.id.cmp(&left.id))
})
.find(|holding| holding.id == requested_id)
.cloned()
}
pub(super) fn holding_options(
holdings: &[SelectedHolding],
definition_id: AccountId,
) -> Vec<SelectedHolding> {
let mut options = holdings
.iter()
.filter(|holding| holding.definition_id == definition_id)
.cloned()
.collect::<Vec<_>>();
options.sort_by_key(|holding| holding.id);
options
}
+197 -18
View File
@@ -17,7 +17,9 @@ use super::{
commitment::{QuoteCommitment, RequestCommitment},
context::fungible_definition,
funding::{funding_commitments, funding_issues, hash_quote},
holding::{decode_fungible_holding, select_holding, wallet_holdings},
holding::{
decode_fungible_holding, holding_options, select_holding, wallet_holdings, SelectedHolding,
},
pair::{derive_pair, is_canonical_pair, PairIds},
position::{
AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch,
@@ -27,7 +29,8 @@ use super::{
QuoteRequest,
};
use crate::account::{
decode_account, parse_base58_id, parse_program_id, program_id_bytes, AccountRead,
account_id_hex, decode_account, parse_base58_id, parse_program_id, program_id_bytes,
AccountRead,
};
const DEFAULT_SLIPPAGE_BPS: u32 = 50;
@@ -206,9 +209,19 @@ fn compute_missing_quote(
let expected_lp = initial_lp - MINIMUM_LIQUIDITY;
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a);
let holding_b = select_holding(&holdings, pair.token_b);
let funding = funding_issues(
let holding_a = select_holding(
&holdings,
pair.token_a,
input.request.holding_a_id.as_deref(),
);
let holding_b = select_holding(
&holdings,
pair.token_b,
input.request.holding_b_id.as_deref(),
);
let lp_destination = select_lp_destination(input, &holdings, pair.lp_definition);
let mut funding = holding_selection_issues(input, pair, &holdings, &holding_a, &holding_b);
funding.extend(funding_issues(
input.snapshot.wallet_available,
pair,
&holding_a,
@@ -216,7 +229,10 @@ fn compute_missing_quote(
&holding_b,
amount_b,
["amountARaw", "amountBRaw"],
);
));
if let Some(error) = lp_destination.error.clone() {
funding.push(error);
}
let can_submit = funding.is_empty();
let mut account_plan = missing_account_plan(
input,
@@ -225,7 +241,7 @@ fn compute_missing_quote(
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: None,
lp: lp_destination.selected.as_ref(),
},
)?;
let sources = account_plan.take_sources();
@@ -245,7 +261,7 @@ fn compute_missing_quote(
actual_b: amount_b,
expected_lp,
lp_guard: MINIMUM_LIQUIDITY,
requires_fresh_lp: true,
requires_fresh_lp: lp_destination.requires_fresh,
sources,
funding: funding_commitment,
warnings: Vec::new(),
@@ -272,7 +288,12 @@ fn compute_missing_quote(
"initialPriceRealRaw": spot_price_q64_64(amount_a, amount_b).to_string(),
"minimumAmountARaw": minimum_a.to_string(),
"minimumAmountBRaw": minimum_b.to_string(),
"requiresFreshLp": true,
"requiresFreshLp": lp_destination.requires_fresh,
"lpDefinitionId": pair.lp_definition.to_string(),
"lpDefinitionIdHex": account_id_hex(pair.lp_definition),
"lpDestinationRequired": lp_destination.error.is_some(),
"lpHoldingOptions": holding_rows(&lp_destination.options),
"selectedLpHoldingId": lp_destination.selected.as_ref().map(|holding| holding.id.to_string()),
"accountPreview": preview,
"errors": funding,
"warnings": [],
@@ -431,11 +452,19 @@ fn compute_active_quote(
return Ok(error);
}
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a);
let holding_b = select_holding(&holdings, pair.token_b);
let lp_holding = select_holding(&holdings, pair.lp_definition);
let requires_fresh_lp = lp_holding.is_none();
let funding = funding_issues(
let holding_a = select_holding(
&holdings,
pair.token_a,
input.request.holding_a_id.as_deref(),
);
let holding_b = select_holding(
&holdings,
pair.token_b,
input.request.holding_b_id.as_deref(),
);
let lp_destination = select_lp_destination(input, &holdings, pair.lp_definition);
let mut funding = holding_selection_issues(input, pair, &holdings, &holding_a, &holding_b);
funding.extend(funding_issues(
input.snapshot.wallet_available,
pair,
&holding_a,
@@ -443,7 +472,10 @@ fn compute_active_quote(
&holding_b,
actual_b,
["maxAmountARaw", "maxAmountBRaw"],
);
));
if let Some(error) = lp_destination.error.clone() {
funding.push(error);
}
let can_submit = funding.is_empty();
let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS {
vec![issue(
@@ -468,7 +500,7 @@ fn compute_active_quote(
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: lp_holding.as_ref(),
lp: lp_destination.selected.as_ref(),
},
)?;
let sources = account_plan.take_sources();
@@ -491,7 +523,7 @@ fn compute_active_quote(
actual_b,
expected_lp,
lp_guard: minimum_lp,
requires_fresh_lp,
requires_fresh_lp: lp_destination.requires_fresh,
sources,
funding: funding_commitments(pair, &holding_a, actual_a, &holding_b, actual_b),
warnings: warning_codes,
@@ -520,7 +552,12 @@ fn compute_active_quote(
"expectedLpRaw": expected_lp.to_string(),
"minimumLpRaw": minimum_lp.to_string(),
"initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(),
"requiresFreshLp": requires_fresh_lp,
"requiresFreshLp": lp_destination.requires_fresh,
"lpDefinitionId": pair.lp_definition.to_string(),
"lpDefinitionIdHex": account_id_hex(pair.lp_definition),
"lpDestinationRequired": lp_destination.error.is_some(),
"lpHoldingOptions": holding_rows(&lp_destination.options),
"selectedLpHoldingId": lp_destination.selected.as_ref().map(|holding| holding.id.to_string()),
"accountPreview": preview,
"errors": funding,
"warnings": warnings,
@@ -545,6 +582,148 @@ fn compute_active_quote(
}))
}
struct LpDestination {
options: Vec<SelectedHolding>,
selected: Option<SelectedHolding>,
requires_fresh: bool,
error: Option<Value>,
}
fn select_lp_destination(
input: &QuoteRequest,
holdings: &[SelectedHolding],
definition_id: AccountId,
) -> LpDestination {
let options = holding_options(holdings, definition_id);
if input.request.create_fresh_lp && input.request.lp_holding_id.is_some() {
return LpDestination {
options,
selected: None,
requires_fresh: false,
error: Some(issue(
"invalid_lp_destination",
"Choose either a wallet holding or a new TokenHolding.",
&["lpHoldingId", "createFreshLp"],
json!({}),
)),
};
}
if input.request.create_fresh_lp {
return LpDestination {
options,
selected: None,
requires_fresh: true,
error: None,
};
}
if input.request.lp_holding_id.is_some() {
let selected = select_holding(
holdings,
definition_id,
input.request.lp_holding_id.as_deref(),
);
let error = selected.is_none().then(|| {
issue(
"invalid_lp_destination",
"Selected LP TokenHolding is unavailable.",
&["lpHoldingId"],
json!({ "available": options.len() }),
)
});
return LpDestination {
options,
selected,
requires_fresh: false,
error,
};
}
match options.as_slice() {
[] => LpDestination {
options,
selected: None,
requires_fresh: true,
error: None,
},
[only] => LpDestination {
selected: Some(only.clone()),
options,
requires_fresh: false,
error: None,
},
_ => LpDestination {
error: Some(issue(
"lp_destination_required",
"Select an LP TokenHolding destination.",
&["lpHoldingId", "createFreshLp"],
json!({ "available": options.len() }),
)),
options,
selected: None,
requires_fresh: false,
},
}
}
fn holding_selection_issues(
input: &QuoteRequest,
pair: PairIds,
holdings: &[SelectedHolding],
holding_a: &Option<SelectedHolding>,
holding_b: &Option<SelectedHolding>,
) -> Vec<Value> {
if !input.snapshot.wallet_available {
return Vec::new();
}
let mut errors = Vec::new();
for (definition_id, requested, selected, field) in [
(
pair.token_a,
input.request.holding_a_id.as_deref(),
holding_a,
"holdingAId",
),
(
pair.token_b,
input.request.holding_b_id.as_deref(),
holding_b,
"holdingBId",
),
] {
let options = holding_options(holdings, definition_id);
if selected.is_some() || (requested.is_none() && options.len() <= 1) {
continue;
}
errors.push(issue(
if requested.is_some() {
"invalid_holding_selection"
} else {
"holding_selection_required"
},
"Select a wallet holding for this token.",
&[field],
json!({
"tokenId": definition_id.to_string(),
"available": options.len(),
}),
));
}
errors
}
fn holding_rows(holdings: &[SelectedHolding]) -> Vec<Value> {
holdings
.iter()
.map(|holding| {
json!({
"holdingId": holding.id.to_string(),
"address": account_id_hex(holding.id),
"balanceRaw": holding.balance.to_string(),
})
})
.collect()
}
fn validate_active_accounts(
input: &QuoteRequest,
pair: PairIds,
+8
View File
@@ -148,6 +148,14 @@ pub struct PositionRequest {
pub token_b_id: String,
pub fee_bps: u32,
#[serde(default)]
pub holding_a_id: Option<String>,
#[serde(default)]
pub holding_b_id: Option<String>,
#[serde(default)]
pub lp_holding_id: Option<String>,
#[serde(default)]
pub create_fresh_lp: bool,
#[serde(default)]
pub amount_a_raw: Option<String>,
#[serde(default)]
pub amount_b_raw: Option<String>,
+190 -64
View File
@@ -144,6 +144,10 @@ fn request(pair: PairIds) -> PositionRequest {
token_a_id: pair.token_a.to_string(),
token_b_id: pair.token_b.to_string(),
fee_bps: 30,
holding_a_id: None,
holding_b_id: None,
lp_holding_id: None,
create_fresh_lp: false,
amount_a_raw: None,
amount_b_raw: None,
max_amount_a_raw: None,
@@ -214,6 +218,69 @@ impl Scenario {
}
}
fn active_scenario(lp_holdings: &[(AccountId, u128)]) -> Scenario {
let mut scenario = Scenario::testnet();
let pair = scenario.pair;
let pool = PoolDefinition {
definition_token_a_id: pair.token_a,
definition_token_b_id: pair.token_b,
vault_a_id: pair.vault_a,
vault_b_id: pair.vault_b,
liquidity_pool_id: pair.lp_definition,
liquidity_pool_supply: 10_000,
reserve_a: 10_000,
reserve_b: 20_000,
fees: 30,
};
scenario.snapshot.pool = account_read(pair.pool, &account(AMM_PROGRAM, Data::from(&pool)));
scenario.snapshot.vault_a =
account_read(pair.vault_a, &token_holding(pair.token_a, pool.reserve_a));
scenario.snapshot.vault_b =
account_read(pair.vault_b, &token_holding(pair.token_b, pool.reserve_b));
scenario.snapshot.lp_definition = account_read(
pair.lp_definition,
&account(
TOKEN_PROGRAM,
Data::from(&TokenDefinition::Fungible {
name: String::from("LP"),
total_supply: pool.liquidity_pool_supply,
metadata_id: None,
authority: Some(pair.lp_definition),
}),
),
);
scenario.snapshot.current_tick = account_read(
pair.current_tick,
&account(
TWAP_PROGRAM,
Data::from(&CurrentTickAccount {
tick: 0,
last_updated: 1_000,
}),
),
);
scenario.snapshot.wallet_accounts = vec![
account_read(
AccountId::new([61; 32]),
&token_holding(pair.token_a, 1_000),
),
account_read(
AccountId::new([62; 32]),
&token_holding(pair.token_b, 2_000),
),
];
scenario.snapshot.wallet_accounts.extend(
lp_holdings
.iter()
.map(|(id, balance)| account_read(*id, &token_holding(pair.lp_definition, *balance))),
);
scenario.request.initial_price_real_raw = None;
scenario.request.max_amount_a_raw = Some(String::from("1000"));
scenario.request.max_amount_b_raw = Some(String::from("3000"));
scenario.request.slippage_bps = Some(50);
scenario
}
fn assert_preview_matches_plan(
quote_value: &Value,
plan_value: &Value,
@@ -244,8 +311,8 @@ fn account_plan_sources_follow_pool_branch() {
let pair = scenario.pair;
let input = scenario.quote_request();
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a);
let holding_b = select_holding(&holdings, pair.token_b);
let holding_a = select_holding(&holdings, pair.token_a, None);
let holding_b = select_holding(&holdings, pair.token_b, None);
let missing = missing_account_plan(
&input,
@@ -337,7 +404,7 @@ fn minimum_pair_is_minimal_on_price_base_side() {
}
#[test]
fn highest_balance_holding_wins_then_lowest_id() {
fn holding_selection_requires_a_choice_when_multiple_exist() {
let definition = AccountId::new([9; 32]);
let holding = |id: u8, balance| SelectedHolding {
id: AccountId::new([id; 32]),
@@ -351,12 +418,15 @@ fn highest_balance_holding_wins_then_lowest_id() {
}),
),
};
let holdings = [holding(4, 10), holding(2, 20), holding(1, 20)];
assert!(select_holding(&holdings, definition, None).is_none());
let selected = select_holding(
&[holding(4, 10), holding(2, 20), holding(1, 20)],
&holdings,
definition,
Some(&AccountId::new([4; 32]).to_string()),
)
.unwrap();
assert_eq!(selected.id, AccountId::new([1; 32]));
assert_eq!(selected.id, AccountId::new([4; 32]));
}
#[test]
@@ -508,7 +578,51 @@ fn context_selects_tokens_without_holdings() {
.unwrap();
assert_eq!(value["tokens"][0]["selectable"], true);
assert_eq!(value["tokens"][0]["sources"], json!(["config"]));
assert!(value["tokens"][0].get("holdingId").is_none());
assert_eq!(value["tokens"][0]["holdings"], json!([]));
assert_eq!(value["programAccounts"], json!([]));
}
#[test]
fn context_exposes_all_compatible_holdings_as_program_accounts() {
let token_id = AccountId::new([3; 32]);
let holding_a = AccountId::new([4; 32]);
let holding_b = AccountId::new([5; 32]);
let config_id = compute_config_pda(AMM_PROGRAM);
let value = context(ContextRequest {
network_id: String::from("testnet"),
network_fingerprint: String::from("block10:abc"),
amm_program_id: amm_program_id(),
wallet_available: true,
config: account_read(config_id, &config_account()),
wallet_accounts: vec![
account_read(holding_b, &token_holding(token_id, 80)),
account_read(holding_a, &token_holding(token_id, 120)),
],
token_definitions: vec![account_read(
token_id,
&token_definition("Token", 1_000_000),
)],
configured_token_ids: vec![account_id_hex(token_id)],
recent_token_ids: Vec::new(),
resolved_token_ids: Vec::new(),
})
.unwrap();
assert_eq!(value["tokens"][0]["balanceRaw"], "200");
assert_eq!(value["tokens"][0]["holdings"].as_array().unwrap().len(), 2);
assert_eq!(
value["programAccounts"][0]["accountId"],
holding_a.to_string()
);
assert_eq!(value["programAccounts"][0]["accountType"], "TokenHolding");
assert_eq!(
value["programAccounts"][0]["state"]["definitionId"],
account_id_hex(token_id)
);
assert_eq!(
value["programAccounts"][1]["accountId"],
holding_b.to_string()
);
}
#[test]
@@ -572,6 +686,31 @@ fn missing_pool_quote_accepts_large_direct_raw_amounts() {
assert!(quote_value.get("depositScaleBps").is_none());
}
#[test]
fn quote_requires_explicit_input_holding_when_multiple_match() {
let mut scenario = Scenario::devnet();
let extra_holding = AccountId::new([63; 32]);
scenario.snapshot.wallet_accounts.push(account_read(
extra_holding,
&token_holding(scenario.pair.token_a, 1_000_000),
));
let ambiguous = scenario.quote();
assert_eq!(ambiguous["canSubmit"], false);
assert!(ambiguous["errors"].as_array().unwrap().iter().any(|error| {
error["code"] == "holding_selection_required"
&& error["blockingFields"] == json!(["holdingAId"])
}));
scenario.request.holding_a_id = Some(extra_holding.to_string());
let selected = scenario.quote();
assert_eq!(selected["canSubmit"], true);
assert_eq!(
selected["accountPreview"][6]["accountId"],
extra_holding.to_string()
);
}
#[test]
fn advancing_clock_does_not_stale_quote() {
let mut scenario = Scenario::testnet();
@@ -601,65 +740,8 @@ fn advancing_clock_does_not_stale_quote() {
#[test]
fn active_pool_quote_uses_ratio_and_existing_lp_holding() {
let mut scenario = Scenario::testnet();
let pair = scenario.pair;
let pool = PoolDefinition {
definition_token_a_id: pair.token_a,
definition_token_b_id: pair.token_b,
vault_a_id: pair.vault_a,
vault_b_id: pair.vault_b,
liquidity_pool_id: pair.lp_definition,
liquidity_pool_supply: 10_000,
reserve_a: 10_000,
reserve_b: 20_000,
fees: 30,
};
scenario.snapshot.pool = account_read(pair.pool, &account(AMM_PROGRAM, Data::from(&pool)));
scenario.snapshot.vault_a =
account_read(pair.vault_a, &token_holding(pair.token_a, pool.reserve_a));
scenario.snapshot.vault_b =
account_read(pair.vault_b, &token_holding(pair.token_b, pool.reserve_b));
scenario.snapshot.lp_definition = account_read(
pair.lp_definition,
&account(
TOKEN_PROGRAM,
Data::from(&TokenDefinition::Fungible {
name: String::from("LP"),
total_supply: pool.liquidity_pool_supply,
metadata_id: None,
authority: Some(pair.lp_definition),
}),
),
);
scenario.snapshot.current_tick = account_read(
pair.current_tick,
&account(
TWAP_PROGRAM,
Data::from(&CurrentTickAccount {
tick: 0,
last_updated: 1_000,
}),
),
);
scenario.snapshot.wallet_accounts = vec![
account_read(
AccountId::new([61; 32]),
&token_holding(pair.token_a, 1_000),
),
account_read(
AccountId::new([62; 32]),
&token_holding(pair.token_b, 2_000),
),
];
let lp_holding = AccountId::new([64; 32]);
scenario.snapshot.wallet_accounts.push(account_read(
lp_holding,
&token_holding(pair.lp_definition, 500),
));
scenario.request.initial_price_real_raw = None;
scenario.request.max_amount_a_raw = Some(String::from("1000"));
scenario.request.max_amount_b_raw = Some(String::from("3000"));
scenario.request.slippage_bps = Some(50);
let scenario = active_scenario(&[(lp_holding, 500)]);
let quote_value = scenario.quote();
assert_eq!(quote_value["poolStatus"], "active_pool");
@@ -679,6 +761,50 @@ fn active_pool_quote_uses_ratio_and_existing_lp_holding() {
assert_preview_matches_plan(&quote_value, &plan_value, None);
}
#[test]
fn active_pool_quote_requires_lp_destination_when_multiple_match() {
let lp_a = AccountId::new([64; 32]);
let lp_b = AccountId::new([65; 32]);
let mut scenario = active_scenario(&[(lp_a, 500), (lp_b, 200)]);
let ambiguous = scenario.quote();
assert_eq!(ambiguous["canSubmit"], false);
assert_eq!(ambiguous["lpDestinationRequired"], true);
assert_eq!(ambiguous["lpHoldingOptions"].as_array().unwrap().len(), 2);
assert!(ambiguous["errors"]
.as_array()
.unwrap()
.iter()
.any(|error| error["code"] == "lp_destination_required"));
scenario.request.lp_holding_id = Some(lp_b.to_string());
let selected = scenario.quote();
assert_eq!(selected["canSubmit"], true);
assert_eq!(selected["selectedLpHoldingId"], lp_b.to_string());
assert_eq!(selected["requiresFreshLp"], false);
}
#[test]
fn active_pool_quote_can_force_fresh_lp_destination() {
let lp_holding = AccountId::new([64; 32]);
let mut scenario = active_scenario(&[(lp_holding, 500)]);
scenario.request.create_fresh_lp = true;
let quote_value = scenario.quote();
assert_eq!(quote_value["canSubmit"], true);
assert_eq!(quote_value["selectedLpHoldingId"], Value::Null);
assert_eq!(quote_value["requiresFreshLp"], true);
let fresh_lp = AccountId::new([66; 32]);
let plan_value = scenario.plan(
quote_value["quoteHash"].as_str().unwrap(),
Some(default_read(fresh_lp)),
);
assert_eq!(plan_value["status"], "ready");
assert_eq!(plan_value["accountIds"][7], account_id_hex(fresh_lp));
assert_eq!(plan_value["signingRequirements"][7], true);
}
#[test]
fn matching_unfunded_quote_has_no_transaction_plan() {
let mut scenario = Scenario::devnet();
+4 -2
View File
@@ -176,14 +176,16 @@ pub enum Instruction {
///
/// Swap direction is determined by the input holding: `user_input_holding`'s token definition
/// selects which pool token is sold. That holding must be signed so the downstream token
/// transfer can debit it; `user_output_holding` only receives and needs no signature.
/// transfer can debit it. `user_output_holding` may be initialized, or fresh and signed so the
/// downstream token transfer can claim and initialize it.
///
/// Required accounts:
/// - AMM Pool (initialized)
/// - Vault Holding Account for Token A (initialized)
/// - Vault Holding Account for Token B (initialized)
/// - User Input Holding Account (initialized, signed) — the token being sold
/// - User Output Holding Account (initialized) — receives the token being bought
/// - User Output Holding Account (initialized, or uninitialized and signed) — receives the
/// token being bought
/// - Current Tick Account, the pool's TWAP PDA derived as
/// `compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id)`; refreshed
/// with the new spot price
+2 -1
View File
@@ -298,7 +298,8 @@ mod amm {
/// Swap some quantity of tokens while maintaining the pool constant product.
///
/// The swap direction is the input holding's own token; `user_input_holding` must be signed so
/// the downstream token transfer can debit it. `user_output_holding` only receives.
/// the downstream token transfer can debit it. `user_output_holding` may be initialized, or
/// fresh and authorized so the downstream transfer can claim and initialize it.
#[expect(
clippy::too_many_arguments,
reason = "instruction interface requires explicit pool, vault, user accounts, and bounds"
+21 -7
View File
@@ -6,7 +6,7 @@ use amm_core::{
pub use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition};
use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID;
use nssa_core::{
account::{AccountId, AccountWithMetadata, Data},
account::{Account, AccountId, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, ProgramId},
};
use twap_oracle_core::compute_current_tick_account_pda;
@@ -49,6 +49,18 @@ fn validate_swap_setup(
pool_def_data
}
fn assert_user_holding_owner_or_fresh(
holding: &AccountWithMetadata,
token_program_id: ProgramId,
message: &str,
) {
assert!(
holding.account.program_owner == token_program_id
|| (holding.account == Account::default() && holding.is_authorized),
"{message}"
);
}
/// Assembles the swap post-states (including the echoed current-tick and clock accounts) and the
/// chained call that refreshes the pool's TWAP current tick from the post-swap spot price.
#[expect(
@@ -188,13 +200,15 @@ pub fn swap_exact_input(
} else {
panic!("Swap exact input: input holding token is not part of the pool");
};
assert_eq!(
user_holding_a.account.program_owner, token_program_id,
"User Token A holding must be owned by the configured Token Program"
assert_user_holding_owner_or_fresh(
&user_holding_a,
token_program_id,
"User Token A holding must be owned by the configured Token Program",
);
assert_eq!(
user_holding_b.account.program_owner, token_program_id,
"User Token B holding must be owned by the configured Token Program"
assert_user_holding_owner_or_fresh(
&user_holding_b,
token_program_id,
"User Token B holding must be owned by the configured Token Program",
);
// The current tick is refreshed by a chained call to the oracle; validate its PDA and the
// clock here so the swap is rejected early with an AMM-level error.
+62
View File
@@ -736,6 +736,14 @@ impl AccountWithMetadataForTests {
}
}
fn fresh_user_output_holding() -> AccountWithMetadata {
AccountWithMetadata {
account: Account::default(),
is_authorized: true,
account_id: AccountId::new([48; 32]),
}
}
fn vault_a_init() -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
@@ -2734,6 +2742,60 @@ fn test_call_swap_chained_call_successful_1() {
assert_update_tick_call(&chained_calls, pool_post.account());
}
#[test]
fn test_call_swap_exact_input_accepts_fresh_authorized_output() {
let fresh_output = AccountWithMetadataForTests::fresh_user_output_holding();
let (_, chained_calls) = swap_exact_input(
AccountWithMetadataForTests::config_init(),
AccountWithMetadataForTests::pool_definition_init(),
AccountWithMetadataForTests::vault_a_init(),
AccountWithMetadataForTests::vault_b_init(),
AccountWithMetadataForTests::user_holding_a(),
fresh_output.clone(),
AccountWithMetadataForTests::current_tick_account_uninit(),
AccountWithMetadataForTests::clock(),
BalanceForTests::add_max_amount_a(),
BalanceForTests::add_max_amount_a_low(),
AMM_PROGRAM_ID,
);
let mut vault_b = AccountWithMetadataForTests::vault_b_init();
vault_b.is_authorized = true;
let expected = ChainedCall::new(
TOKEN_PROGRAM_ID,
vec![vault_b, fresh_output],
&token_core::Instruction::Transfer {
amount_to_transfer: BalanceForTests::swap_amount_out_b(),
},
)
.with_pda_seeds(vec![compute_vault_pda_seed(
IdForTests::pool_definition_id(),
IdForTests::token_b_definition_id(),
)]);
assert_eq!(chained_calls[1], expected);
}
#[test]
#[should_panic(expected = "User Token B holding must be owned by the configured Token Program")]
fn test_call_swap_exact_input_rejects_fresh_unauthorized_output() {
let mut fresh_output = AccountWithMetadataForTests::fresh_user_output_holding();
fresh_output.is_authorized = false;
let _ = swap_exact_input(
AccountWithMetadataForTests::config_init(),
AccountWithMetadataForTests::pool_definition_init(),
AccountWithMetadataForTests::vault_a_init(),
AccountWithMetadataForTests::vault_b_init(),
AccountWithMetadataForTests::user_holding_a(),
fresh_output,
AccountWithMetadataForTests::current_tick_account_uninit(),
AccountWithMetadataForTests::clock(),
BalanceForTests::add_max_amount_a(),
BalanceForTests::add_max_amount_a_low(),
AMM_PROGRAM_ID,
);
}
#[test]
fn test_call_swap_chained_call_successful_2() {
let (post_states, chained_calls) = swap_exact_input(
+55
View File
@@ -34,6 +34,10 @@ impl Keys {
PrivateKey::try_new([33; 32]).expect("valid private key")
}
fn fresh_output() -> PrivateKey {
PrivateKey::try_new([35; 32]).expect("valid private key")
}
fn admin() -> PrivateKey {
PrivateKey::try_new([34; 32]).expect("valid private key")
}
@@ -131,6 +135,10 @@ impl Ids {
AccountId::from(&PublicKey::new_from_private_key(&Keys::user_lp()))
}
fn fresh_output() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::fresh_output()))
}
fn admin() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::admin()))
}
@@ -2825,6 +2833,53 @@ fn amm_swap_a_to_b() {
assert_eq!(tick_account.tick, expected_tick);
}
#[test]
fn amm_swap_exact_input_creates_fresh_output_holding() {
let mut state = state_for_amm_tests();
let instruction = amm_core::Instruction::SwapExactInput {
swap_amount_in: Balances::swap_amount_in(),
min_amount_out: Balances::swap_min_out(),
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
Ids::amm_program(),
vec![
Ids::config(),
Ids::pool_definition(),
Ids::vault_a(),
Ids::vault_b(),
Ids::user_a(),
Ids::fresh_output(),
Ids::current_tick_account(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![current_nonce(&state, Ids::user_a()), Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::user_a(), &Keys::fresh_output()],
);
state
.transition_from_public_transaction(&PublicTransaction::new(message, witness_set), 0, 0)
.unwrap();
assert_eq!(
state.get_account_by_id(Ids::fresh_output()),
Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_b_definition(),
balance: Balances::user_b_swap_2() - Balances::user_b_init(),
}),
nonce: Nonce(1),
}
);
}
#[test]
fn amm_swap_exact_output_refreshes_current_tick() {
let mut state = state_for_amm_tests();