feat(apps/amm): let users pick which LP account to remove from

The remove-liquidity sheet burned from a single LP holding the detail page
picked. A pool position can span several LP accounts (each add mints a
fresh one), so add a "Remove from" account selector above the actions:
picking an account repoints the burn and re-prices for its balance, so the
percentage, the previewed A/B amounts, and the slippage floors all follow
the chosen holding. It defaults to the largest holding.

PoolDetailPage passes the pool's LP holdings (encoding-tolerant match) into
the dialog; the selector filters them by account type and drives
lpHoldingId + lpBalance on selection.

Extend the remove-liquidity e2e test to assert the selector renders,
preselects a real holding, and that the dialog's burn account tracks it.
This commit is contained in:
r4bbit
2026-08-25 12:48:36 +02:00
parent 68c0e9cd23
commit 3994ba3055
3 changed files with 127 additions and 10 deletions
@@ -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
+3
View File
@@ -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
})
+53 -10
View File
@@ -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(