feat(apps/amm): pick the token account per side when creating a pool

Add per-side account selectors to the create-pool form, sourced from the
wallet's token holdings, so a user holding a token in several accounts chooses
which funds each deposit. A single holding auto-selects.

Mirrors the swap card: the selector lives inside the token input card, below
the token button. AmmTokenAmountSurface gains an optional footer slot (unchanged
when unset, so add-liquidity renders as before); TokenAmountInput fills it with a
ProgramAccountSelector filtered on the token's base58 definitionId.

- LiquidityPage fetches backend.tokenHoldings(), refetching when the wallet opens
- NewPositionForm routes the chosen holdings into submissionSnapshot's createPool
  call; canConfirm now requires both holdings when creating a pool (add-liquidity
  is untouched — it enumerates holdings server-side)
- create-pool.mjs selects the funding account for each side before submitting
This commit is contained in:
r4bbit
2026-08-11 12:04:48 +02:00
parent 86575a65aa
commit b1b4631234
6 changed files with 174 additions and 8 deletions
@@ -22,12 +22,20 @@ Rectangle {
property Component adjustment
property real adjustmentWidth: 0
property real adjustmentHeight: 0
// Optional full-width content rendered inside the card, below the amount/token
// row (e.g. the create-pool account selector). Null → the card is unchanged.
property Component footer
property real footerHeight: 0
readonly property bool footerActive: root.footer !== null
property alias footerItem: footerLoader.item
signal amountEdited(string value)
signal amountEditingFinished(string value)
signal supportingActionClicked
implicitHeight: Math.max(110, contentRow.implicitHeight + 24)
implicitHeight: root.footerActive
? Math.max(110, contentRow.implicitHeight + root.footerHeight + 30)
: Math.max(110, contentRow.implicitHeight + 24)
radius: 16
color: root.muted ? root.theme.colors.panelBg : root.theme.colors.inputBg
border.color: root.invalid
@@ -46,11 +54,16 @@ Rectangle {
RowLayout {
id: contentRow
anchors.fill: parent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
// When a footer is present it takes the bottom of the card; otherwise the
// row fills the card exactly as before (12px inset).
anchors.bottom: footerLoader.top
anchors.leftMargin: 16
anchors.rightMargin: 16
anchors.topMargin: 12
anchors.bottomMargin: 12
anchors.bottomMargin: root.footerActive ? 6 : 12
spacing: 10
ColumnLayout {
@@ -189,6 +202,21 @@ Rectangle {
}
}
Loader {
id: footerLoader
active: root.footerActive
visible: active
sourceComponent: root.footer
height: active ? root.footerHeight : 0
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 16
anchors.rightMargin: 16
anchors.bottomMargin: active ? 12 : 0
}
Behavior on color {
ColorAnimation { duration: 180 }
}
@@ -21,6 +21,12 @@ AmmActionCard {
property var newPositionContext: ({})
property var flowState: ({})
// Wallet token holdings (backend.tokenHoldings()) for the create-pool account
// selectors, narrowed per side by the selected token's base58 definitionId. The
// chosen holdings feed the createPool call via submissionSnapshot().
property var holdings: []
readonly property string selectedHoldingAId: tokenAInput.selectedHoldingId
readonly property string selectedHoldingBId: tokenBInput.selectedHoldingId
property string selectedTokenAId: ""
property string selectedTokenBId: ""
property int selectedFeeBps: 30
@@ -86,10 +92,17 @@ AmmActionCard {
&& root.selectedTokenBId.length > 0
&& root.selectedTokenAId !== root.selectedTokenBId
readonly property bool resolvingToken: root.resolvingTokenId.length > 0
// Creating a pool submits caller-provided A/B holdings (see submissionSnapshot);
// require both selectors resolved. Add-liquidity enumerates holdings server-side,
// so it does not gate on these.
readonly property bool holdingsReady: !root.missingPool
|| (root.selectedHoldingAId.length > 0
&& root.selectedHoldingBId.length > 0)
readonly property bool canConfirm: root.quotePayload.status === "ok"
&& root.quotePayload.canSubmit === true
&& root.quoteMatchesPair()
&& String(root.quotePayload.quoteHash || "").length > 0
&& root.holdingsReady
&& !root.contextLoading
&& !root.quoteLoading
&& !root.quoteStale
@@ -248,6 +261,10 @@ AmmActionCard {
tokenData: root.tokenA.definitionId ? root.tokenA : null
tokens: root.tokens
selectedTokenId: root.selectedTokenAId
holdings: root.holdings
holdingDefinitionId: root.selectedTokenAId
showHoldingSelector: root.missingPool && root.hasPair
selectorObjectName: "newPositionAccountSelectorA"
tokenInvalid: root.tokenHasError("A")
tokenSelectionEnabled: !root.contextLoading && !root.submitting
adjustment: root.missingPool ? priceAmountAAdjustment : null
@@ -296,6 +313,10 @@ AmmActionCard {
tokenData: root.tokenB.definitionId ? root.tokenB : null
tokens: root.tokens
selectedTokenId: root.selectedTokenBId
holdings: root.holdings
holdingDefinitionId: root.selectedTokenBId
showHoldingSelector: root.missingPool && root.hasPair
selectorObjectName: "newPositionAccountSelectorB"
tokenInvalid: root.tokenHasError("B")
tokenSelectionEnabled: !root.contextLoading && !root.submitting
adjustment: root.missingPool ? priceAmountBAdjustment : null
@@ -1440,8 +1461,10 @@ AmmActionCard {
// Canonical-order holdings for the create path's createPool call: the
// request's tokenAId/amountARaw are canonical, so holdingAId must be the
// canonical token A's holding too (createPool re-canonicalizes as a no-op).
"holdingAId": String((root.displayIsCanonical ? root.tokenA : root.tokenB).holdingId || ""),
"holdingBId": String((root.displayIsCanonical ? root.tokenB : root.tokenA).holdingId || ""),
// The user picks these via the per-side account selectors; selectedHoldingA
// is display token A's holding, so it aligns with tokenA the same way.
"holdingAId": String(root.displayIsCanonical ? root.selectedHoldingAId : root.selectedHoldingBId),
"holdingBId": String(root.displayIsCanonical ? root.selectedHoldingBId : root.selectedHoldingAId),
"pairText": qsTr("%1 / %2").arg(root.shortTokenName(root.tokenA)).arg(root.shortTokenName(root.tokenB)),
"feeText": root.feeLabel(root.selectedFeeBps),
"depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"),
@@ -2,6 +2,8 @@ pragma ComponentBehavior: Bound
import QtQuick
import Logos.Wallet
import "../shared"
AmmTokenAmountSurface {
@@ -18,6 +20,21 @@ AmmTokenAmountSurface {
property bool tokenSelectionEnabled: true
property bool editPending: false
property string pendingValue: ""
// Account selector (create-pool only): the wallet holdings to pick from, the
// token's base58 definitionId to filter by, and whether to show it at all. The
// chosen holding id is exposed as selectedHoldingId. Mirrors the swap card, where
// the selector sits inside the input card below the token button.
property var holdings: []
property string holdingDefinitionId: ""
property bool showHoldingSelector: false
// objectName forwarded to the account selector, so UI tests can pick the
// funding holding for this side deterministically.
property string selectorObjectName: ""
readonly property string selectedHoldingId: root.footerItem && root.footerItem.selectedAccountId
? String(root.footerItem.selectedAccountId) : ""
footer: root.showHoldingSelector ? accountFooter : null
footerHeight: root.footerItem ? root.footerItem.implicitHeight : 0
property var disabledReasonForCode: function(code) {
return qsTr("This token is unavailable (%1).").arg(code || "unknown")
}
@@ -65,6 +82,40 @@ AmmTokenAmountSurface {
onTriggered: root.commitPendingEdit()
}
Component {
id: accountFooter
// The Loader stretches this wrapper to the card width; the selector takes the
// right half, right-aligned, matching the swap card's account selector.
Item {
implicitHeight: footerSelector.implicitHeight
property alias selectedAccountId: footerSelector.selectedAccountId
ProgramAccountSelector {
id: footerSelector
objectName: root.selectorObjectName
width: Math.round(parent.width / 2)
anchors.right: parent.right
anchors.top: parent.top
sourceModel: root.holdings
accountType: "TokenHolding"
stateField: "definitionId"
stateValue: root.holdingDefinitionId
selectionMode: ProgramAccountSelector.Input
showWhenSingle: true
textAlignment: Text.AlignRight
backgroundColor: root.theme.colors.panelBg
hoverColor: root.theme.colors.panelHoverBg
textColor: root.theme.colors.textPrimary
secondaryTextColor: root.theme.colors.textSecondary
borderColor: root.theme.colors.borderStrong
focusColor: root.theme.colors.ctaBg
}
}
}
Component {
id: tokenActions
+22
View File
@@ -17,6 +17,27 @@ Item {
property var runtime: null
readonly property NewPositionFlow flow: newPositionFlow
// Wallet token holdings (backend.tokenHoldings()) feeding the create-pool
// account selectors; refetched when the wallet opens.
property var holdings: []
function refreshHoldings() {
if (!root.backend || root.runtime === null)
return
root.runtime.watch(root.backend.tokenHoldings(),
function(list) { root.holdings = list },
function(err) { console.warn("tokenHoldings error:", err) })
}
onBackendChanged: root.refreshHoldings()
onRuntimeChanged: root.refreshHoldings()
Component.onCompleted: root.refreshHoldings()
Connections {
target: root.backend
function onIsWalletOpenChanged() { root.refreshHoldings() }
}
readonly property int pageMargin: width < 640 ? 16 : 24
readonly property int contentMaxWidth: 1200
readonly property bool wideLayout: width >= 760
@@ -215,6 +236,7 @@ Item {
? qsTr("Specify the token amounts for your liquidity contribution.")
: qsTr("Choose two tokens and a fee tier for this position.")
showRefreshAction: false
holdings: root.holdings
newPositionContext: newPositionFlow.newPositionContext
flowState: newPositionFlow.viewState
+37 -1
View File
@@ -63,9 +63,34 @@ async function formState(app, formId) {
amountB: get("amountB"),
submitError: get("submitError"),
transactionId: get("transactionId"),
selectedHoldingAId: get("selectedHoldingAId"),
selectedHoldingBId: get("selectedHoldingBId"),
};
}
// Pick the funding account for a create-pool side. The selector auto-selects a
// single holding, but choose it explicitly (robust to multi-account wallets):
// wait for the holdings to populate, then select the first match. canConfirm now
// requires both A/B holdings before a pool can be created.
async function selectAccount(app, selectorObjectName) {
// The selector lives in a Loader that instantiates only once the pool is known
// to be missing, so it may render a frame after missingPool flips — wait for it.
let id;
await app.waitFor(
async () => { id = await idByObjectName(app, selectorObjectName); },
{ timeout: 10000, interval: 300, description: `${selectorObjectName} to render` },
);
await app.waitFor(
async () => { if ((await prop(app, id, "hasFunds")) !== true) throw new Error("no matching holdings yet"); },
{ timeout: 10000, interval: 300, description: `${selectorObjectName} holdings to load` },
);
await evaluate(app, id, "setSelection(accountIdFor(matchingAccounts[0]), false)");
await app.waitFor(
async () => { if (!(await prop(app, id, "selectedAccountId"))) throw new Error("holding not selected yet"); },
{ timeout: 5000, interval: 200, description: `${selectorObjectName} holding selected` },
);
}
async function saveShot(app, name) {
const shot = await ignore(() => app.screenshot());
if (shot && shot.image) {
@@ -154,13 +179,24 @@ test("amm liquidity: create the A/C pool", async (app) => {
);
await evaluate(app, formId, "requestQuote(true)");
// 3. Wait for a submittable create quote (missing pool + funded minimum deposit).
// 3. Wait for the missing-pool quote (which makes the per-side account selectors
// render), pick the funding account for each side, then wait for a submittable
// create quote — canConfirm needs the funded minimum deposit AND both holdings.
try {
await app.waitFor(
async () => {
const s = await formState(app, formId);
if (s.poolStatus === "active_pool")
throw new Error("A/C pool already exists — reset the testnet (only A/B should be seeded)");
if (!s.missingPool) throw new Error("pool status not resolved yet");
},
{ timeout: 20000, interval: 500, description: "missing-pool quote" },
);
await selectAccount(app, "newPositionAccountSelectorA");
await selectAccount(app, "newPositionAccountSelectorB");
await app.waitFor(
async () => {
const s = await formState(app, formId);
if (!s.canConfirm) throw new Error("create CTA not ready yet");
},
{ timeout: 20000, interval: 500, description: "create CTA ready" },
+8 -2
View File
@@ -266,8 +266,14 @@ TestCase {
verify(amountBInput)
compare(amountAInput.selectedTokenId, tokenLow)
compare(amountBInput.selectedTokenId, tokenHigh)
verify(amountAInput.height <= 114)
verify(amountBInput.height <= 114)
// missing_pool now renders the holding-selector footer, so the input card grows by the
// footer's own height plus its extra bottom spacing (the surface adds footerHeight + 30
// when a footer is active vs + 24 without one, i.e. footerHeight + 6 over the pre-footer
// bound). Keep the compactness guard, but make it footer-aware.
verify(amountAInput.footerActive) // the holding-selector footer is present in missing_pool
verify(amountBInput.footerActive)
verify(amountAInput.height <= 114 + amountAInput.footerHeight + 6)
verify(amountBInput.height <= 114 + amountBInput.footerHeight + 6)
verify(findChild(form, "priceAmountAField"))
verify(findChild(form, "priceAmountBField"))