From b1b4631234e2802a1e12967589ab7cf049479514 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:43:06 +0200 Subject: [PATCH] feat(apps/amm): pick the token account per side when creating a pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../liquidity/AmmTokenAmountSurface.qml | 34 +++++++++++-- .../components/liquidity/NewPositionForm.qml | 27 +++++++++- .../components/liquidity/TokenAmountInput.qml | 51 +++++++++++++++++++ apps/amm/qml/pages/LiquidityPage.qml | 22 ++++++++ apps/amm/tests/create-pool.mjs | 38 +++++++++++++- apps/amm/tests/qml/tst_NewPositionForm.qml | 10 +++- 6 files changed, 174 insertions(+), 8 deletions(-) diff --git a/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml b/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml index 39ccd5d..8a22236 100644 --- a/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml +++ b/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml @@ -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 } } diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index a8b15d3..02f7b45 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -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"), diff --git a/apps/amm/qml/components/liquidity/TokenAmountInput.qml b/apps/amm/qml/components/liquidity/TokenAmountInput.qml index 311276c..a8e0982 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -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 diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 49ccc17..6c9d6f5 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -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 diff --git a/apps/amm/tests/create-pool.mjs b/apps/amm/tests/create-pool.mjs index 2d8c5af..1d55663 100644 --- a/apps/amm/tests/create-pool.mjs +++ b/apps/amm/tests/create-pool.mjs @@ -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" }, diff --git a/apps/amm/tests/qml/tst_NewPositionForm.qml b/apps/amm/tests/qml/tst_NewPositionForm.qml index cf6c06a..15a1c92 100644 --- a/apps/amm/tests/qml/tst_NewPositionForm.qml +++ b/apps/amm/tests/qml/tst_NewPositionForm.qml @@ -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"))