mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(wallet): add reusable program account selector
This commit is contained in:
+8
-8
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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("e_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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user