mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 14:11:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user