diff --git a/apps/amm/qml/components/liquidity/RemoveLiquidityDialog.qml b/apps/amm/qml/components/liquidity/RemoveLiquidityDialog.qml index 4ccb5ca..7522141 100644 --- a/apps/amm/qml/components/liquidity/RemoveLiquidityDialog.qml +++ b/apps/amm/qml/components/liquidity/RemoveLiquidityDialog.qml @@ -2,6 +2,8 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Logos.Wallet + import "AmountMath.js" as AmountMath // Remove-liquidity sheet: pick how much of the position to withdraw, preview what @@ -31,6 +33,13 @@ Popup { // exceed what a single withdrawal reaches. property string lpBalanceTotal: "0" property string lpHoldingId: "" + // Every LP holding for this pool the burn can draw on (a split position spans + // several). The source selector lists these; picking one drives lpHoldingId + + // lpBalance so the preview and submit follow the chosen account. + property var lpHoldings: [] + // Cleared on every open; the selector defaults to the caller's primary holding + // once its model settles, after which the user's pick is authoritative. + property bool lpSelectorPrimed: false readonly property bool positionIsSplit: AmountMath.isUnsigned(root.lpBalanceTotal) && AmountMath.compare(root.lpBalanceTotal, root.lpBalance) > 0 @@ -87,6 +96,8 @@ Popup { root.lpBalance = String(position.lpBalance || "0") root.lpBalanceTotal = String(position.lpBalanceTotal || position.lpBalance || "0") root.lpHoldingId = String(position.lpHoldingId || "") + root.lpHoldings = position.lpHoldings || [] + root.lpSelectorPrimed = false root.holdingAId = String(position.holdingAId || "") root.holdingBId = String(position.holdingBId || "") root.percent = 50 @@ -105,6 +116,18 @@ Popup { root.requestQuote() } + // A burn names one LP account, so default the source selector to the caller's + // primary (largest) holding once its model has settled. Runs once per open — + // the selector may populate its rows over several ticks, so retry until the + // primary is actually selectable; thereafter the user's pick wins. + function primeLpSelector() { + if (root.lpSelectorPrimed || !lpSourceSelector.hasFunds) + return + lpSourceSelector.setSelection(root.lpHoldingId, false) + if (lpSourceSelector.selectionValid) + root.lpSelectorPrimed = true + } + onPercentChanged: root.requestQuote() onSlippageBpsChanged: root.requestQuote() @@ -413,6 +436,54 @@ Popup { } } + // ── Source account ─────────────────────────────────────────────────── + // A withdrawal draws on one LP account. When the position spans several + // (each add minted a fresh one), pick which to draw down; the percentage + // and the preview above track the selected account's balance. + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + Layout.fillWidth: true + text: qsTr("Remove from") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + font.weight: Font.DemiBold + } + + ProgramAccountSelector { + id: lpSourceSelector + + objectName: "lpSourceSelector" + Layout.preferredWidth: Math.round(parent.width * 0.55) + sourceModel: root.lpHoldings + accountType: "TokenHolding" + selectionMode: ProgramAccountSelector.Input + showWhenSingle: true + textAlignment: Text.AlignRight + accessibleName: qsTr("LP token account to remove from") + backgroundColor: root.theme.colors.inputBg + 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 + + // A pick (or the auto-prime) is the source of truth: point the burn + // at that account and re-price for its balance. + onSelectionChanged: function(accountId, createNew) { + if (String(accountId || "").length === 0) + return + root.lpHoldingId = String(accountId) + root.lpBalance = lpSourceSelector.selectedBalance + root.requestQuote() + } + onModelRevisionChanged: root.primeLpSelector() + Component.onCompleted: root.primeLpSelector() + } + } + // ── Actions ────────────────────────────────────────────────────────── RowLayout { Layout.fillWidth: true diff --git a/apps/amm/qml/pages/PoolDetailPage.qml b/apps/amm/qml/pages/PoolDetailPage.qml index af7df89..5956041 100644 --- a/apps/amm/qml/pages/PoolDetailPage.qml +++ b/apps/amm/qml/pages/PoolDetailPage.qml @@ -376,6 +376,9 @@ Item { "lpBalance": root.lpHoldingBalance, "lpBalanceTotal": root.lpBalanceTotal, "lpHoldingId": root.lpHoldingId, + // Every LP holding for this pool, so the sheet can offer the choice of + // which to burn from (encoding-tolerant match, same as applyHoldings). + "lpHoldings": root.holdingsFor(root.lpDefinitionId), "holdingAId": root.holdingAId, "holdingBId": root.holdingBId }) diff --git a/apps/amm/tests/remove-liquidity.mjs b/apps/amm/tests/remove-liquidity.mjs index af04c21..a6ad82b 100644 --- a/apps/amm/tests/remove-liquidity.mjs +++ b/apps/amm/tests/remove-liquidity.mjs @@ -231,22 +231,65 @@ test("amm liquidity: remove from the A/B pool", async (app) => { // The dropdown entry's synthetic click may not take; fall back to the page's // openRemoveDialog(), the exact handler the entry's onActivated invokes. - const dialogId = await idByObjectName(app, "removeLiquidityDialog"); try { await app.waitFor( - async () => { if ((await prop(app, dialogId, "visible")) !== true) throw new Error("dialog not visible"); }, - { timeout: 4000, interval: 300, description: "remove dialog open" }, + async () => { if (!(await maybeIdByObjectName(app, "removeLiquidityDialog"))) throw new Error("no dialog"); }, + { timeout: 4000, interval: 300, description: "remove dialog present" }, ); } catch { console.log(" remove-entry click didn't take — opening the dialog via evaluate"); await ignore(() => evaluate(app, detailId, "openRemoveDialog()")); - await app.waitFor( - async () => { if ((await prop(app, dialogId, "visible")) !== true) throw new Error("dialog not visible"); }, - { timeout: 8000, interval: 300, description: "remove dialog open (after evaluate)" }, + } + const dialogId = await idByObjectName(app, "removeLiquidityDialog"); + await app.waitFor( + async () => { if ((await prop(app, dialogId, "visible")) !== true) throw new Error("dialog not visible"); }, + { timeout: 8000, interval: 300, description: "remove dialog open" }, + ); + + // 5. The LP source selector must render, list the wallet's LP holding(s) for this + // pool, and preselect one. A burn names exactly ONE LP account, so this is what + // the % applies to and what the submit spends from — assert it points at a real + // holding and that the dialog's lpHoldingId tracks the selection. + const selectorId = await maybeIdByObjectName(app, "lpSourceSelector"); + if (!selectorId) { + await saveShot(app, "remove-liquidity-no-selector"); + throw new Error( + "lpSourceSelector did not render (the LP-account selector is missing). " + + `Dialog state: ${JSON.stringify(await dialogState(app, dialogId))}`, ); } + console.log( + ` lpSourceSelector: visible=${await prop(app, selectorId, "visible")} ` + + `criteriaReady=${await prop(app, selectorId, "criteriaReady")} ` + + `hasFunds=${await prop(app, selectorId, "hasFunds")} ` + + `lpHoldings=${JSON.stringify(await prop(app, dialogId, "lpHoldings"))}`, + ); + try { + await app.waitFor( + async () => { + if ((await prop(app, selectorId, "hasFunds")) !== true) + throw new Error("selector lists no LP holdings for this pool"); + if (!(await prop(app, selectorId, "selectedAccountId"))) + throw new Error("selector has not preselected an LP account"); + }, + { timeout: 15000, interval: 300, description: "LP source account preselected" }, + ); + } catch (e) { + await saveShot(app, "remove-liquidity-no-selection"); + throw new Error(`${e.message}. Dialog state: ${JSON.stringify(await dialogState(app, dialogId))}`); + } + const sourceAccountId = await prop(app, selectorId, "selectedAccountId"); + const dialogHoldingId = await prop(app, dialogId, "lpHoldingId"); + if (String(sourceAccountId) !== String(dialogHoldingId)) + throw new Error( + `burn account mismatch: selector=${sourceAccountId} dialog.lpHoldingId=${dialogHoldingId}`, + ); + console.log( + ` removing from LP account ${String(sourceAccountId).slice(0, 10)}… ` + + `(balance ${await prop(app, selectorId, "selectedBalance")}) ✓`, + ); - // 5. The dialog opens at 50% by default. Click the 50% preset to make the choice + // 6. The dialog opens at 50% by default. Click the 50% preset to make the choice // explicit (a no-op on the default) and confirm it registers. Then wait for the // quote to settle so the Remove CTA is submittable (needs the resolved A/B/LP // destination holdings, which PoolDetailPage passed into openFor()). @@ -276,7 +319,7 @@ test("amm liquidity: remove from the A/B pool", async (app) => { console.log(` removing ${REMOVE_PERCENT}%: A=${primed.amountA} B=${primed.amountB} (lp=${primed.lpAmount})`); await saveShot(app, "remove-liquidity-primed"); - // 6. Submit the removal. On success the dialog emits removed(tx) and closes itself; + // 7. Submit the removal. On success the dialog emits removed(tx) and closes itself; // on failure it stays open with submitError set. const confirmId = await idByObjectName(app, "removeConfirmButton"); await app.inspector.send("click", { objectId: confirmId }); @@ -292,7 +335,7 @@ test("amm liquidity: remove from the A/B pool", async (app) => { } } - // 7. Wait for the submit to complete: the dialog closes on success. Surface any + // 8. Wait for the submit to complete: the dialog closes on success. Surface any // submitError immediately rather than waiting out the timeout. try { await app.waitFor( @@ -309,7 +352,7 @@ test("amm liquidity: remove from the A/B pool", async (app) => { } console.log(" remove submitted (dialog closed) ✓"); - // 8. Verify ON-CHAIN: the pool's reserveA must have shrunk by the withdrawal. + // 9. Verify ON-CHAIN: the pool's reserveA must have shrunk by the withdrawal. const after = await readReserveA(app); try { await app.waitFor(