From 62d133e909e8da8e668be6c4e551a11c5ffde44b Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:58:27 +0200 Subject: [PATCH] feat(amm): let LPs choose the LP-token destination account Add-liquidity minted a fresh LP account every time, fragmenting a position across holdings. The New position form now has an LP-destination selector (the same Input-mode component as the token funding rows): add-liquidity preselects the wallet's existing LP holding so deposits consolidate, while create-pool has none and mints a fresh one. - addLiquidityQuote returns lpDefinitionId (base58) so the form matches holdings - createPool/addLiquidity submit into the chosen holding, else create-fresh - e2e: add-liquidity waits for the preselect; create-pool asserts fresh-account --- .../components/liquidity/NewPositionForm.qml | 47 +++++++++++++++++++ apps/amm/qml/state/NewPositionFlow.qml | 23 +++++++-- apps/amm/src/AmmUiBackend.rep | 4 +- apps/amm/tests/add-liquidity.mjs | 22 +++++++-- apps/amm/tests/create-pool.mjs | 14 ++++++ modules/amm/ffi/src/api/liquidity.rs | 3 ++ modules/amm/src/amm_module_impl.cpp | 3 +- 7 files changed, 106 insertions(+), 10 deletions(-) diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index e81079c..42e2934 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -31,6 +31,11 @@ AmmActionCard { readonly property string selectedHoldingBId: tokenBInput.selectedHoldingId readonly property string selectedBalanceA: tokenAInput.selectedBalance readonly property string selectedBalanceB: tokenBInput.selectedBalance + // The pool's LP token definition (base58), surfaced by the active-pool add-liquidity + // quote, so the LP-destination selector can offer the wallet's existing LP holdings. + // Empty for create-pool (no pool yet) — the Output selector then shows create-new only. + readonly property string lpDefinitionId: root.quoteMatchesSelectedPair(root.activePoolQuote) + ? String(root.activePoolQuote.lpDefinitionId || "") : "" property string selectedTokenAId: "" property string selectedTokenBId: "" property int selectedFeeBps: 30 @@ -566,6 +571,44 @@ AmmActionCard { ? root.quotePayload.minimumLp : root.quotePayload.lockedLp) } + + // LP destination — grouped with the LP figures above. Same Input-mode account + // selector as the token funding rows: preselects the wallet's existing LP holding + // for this pool (consolidating rather than fragmenting), or shows "New LP account" + // when there's none (create-pool, or a pool you don't hold) so NewPositionFlow + // mints a fresh one. + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + text: qsTr("Receive LP into") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + } + + ProgramAccountSelector { + id: lpSelector + objectName: "lpDestinationSelector" + Layout.preferredWidth: Math.round(parent.width / 2) + sourceModel: root.holdings + accountType: "TokenHolding" + stateField: "definitionId" + stateValue: root.lpDefinitionId + selectionMode: ProgramAccountSelector.Input + showWhenSingle: true + textAlignment: Text.AlignRight + emptyInputText: qsTr("New LP account") + accessibleName: qsTr("LP token destination account") + 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 + } + } } SubmittedTransaction { @@ -1404,6 +1447,10 @@ AmmActionCard { // 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), + // LP-token destination: an existing LP holding, or a fresh account when createLpNew. + // The submit flow (NewPositionFlow) only calls createAccountPublic() when createLpNew. + "lpHoldingId": String(lpSelector.selectedAccountId || ""), + "createLpNew": lpSelector.createNewSelected, // The add path's slippage floor on the LP minted (orientation-independent), // taken from the active-pool quote; ignored by the create path. "minLp": String(root.quotePayload.minimumLp || ""), diff --git a/apps/amm/qml/state/NewPositionFlow.qml b/apps/amm/qml/state/NewPositionFlow.qml index 4edcdd3..bdb7ad2 100644 --- a/apps/amm/qml/state/NewPositionFlow.qml +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -212,7 +212,10 @@ QtObject { "reserveA": String(pool.reserveA || "0"), "reserveB": String(pool.reserveB || "0"), "poolFeeBps": pool.feeBps, - "price": String(quote.price || "0") + "price": String(quote.price || "0"), + // The pool's LP token (base58, matching the holdings' definitionId) so the form + // can offer the wallet's existing LP holdings as the mint destination. + "lpDefinitionId": String(quote.lpDefinitionId || "") } } @@ -242,6 +245,12 @@ QtObject { // holding, so the caller provides a fresh account: create one, then submit. No // confirmation poll yet (transactionStatus is pending an upstream dependency). function createPool(snapshot) { + // If the user picked an existing LP holding, mint straight into it; otherwise create a + // fresh account first. Create-pool has no existing LP, so it always takes the fresh path. + if (!snapshot.createLpNew && String(snapshot.lpHoldingId || "").length > 0) { + root.submitCreatePool(snapshot, String(snapshot.lpHoldingId)) + return + } root.runtime.watch(root.backend.createAccountPublic(), function(lpId) { if (!lpId || String(lpId).length === 0) { @@ -289,11 +298,15 @@ QtObject { }) } - // Add liquidity to an existing pool via the new addLiquidity op. Like createPool a fresh - // LP holding receives the minted LP, so create one then submit. The submit reuses the - // addLiquidityQuote result (maxAmounts + minimumLp) carried on the snapshot. No - // confirmation poll yet. + // Add liquidity to an existing pool via the addLiquidity op. The minted LP goes to the + // holding the user chose (createLpNew=false), consolidating an existing position — or to a + // fresh account when they opt to create one. The submit reuses the addLiquidityQuote result + // (maxAmounts + minimumLp) carried on the snapshot. No confirmation poll yet. function addLiquidity(snapshot) { + if (!snapshot.createLpNew && String(snapshot.lpHoldingId || "").length > 0) { + root.submitAddLiquidity(snapshot, String(snapshot.lpHoldingId)) + return + } root.runtime.watch(root.backend.createAccountPublic(), function(lpId) { if (!lpId || String(lpId).length === 0) { diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 3e95f2f..f5cf80f 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -120,7 +120,9 @@ class AmmUiBackend // carries { tokenAId, tokenBId, maxAmountA, maxAmountB, slippageBps } (ids hex or // base58). Reads the pool and returns { status:"ok", error:"", amountA, amountB // (the actual ratio-matched deposits, display order), expectedLp, minimumLp (the - // slippage floor on the LP minted — the submit's min_amount_liquidity), price }. On + // slippage floor on the LP minted — the submit's min_amount_liquidity), price, + // lpDefinitionId (the pool's LP token, base58 — so the UI can offer existing LP + // holdings as the mint destination) }. On // failure { status:"error", error: } — no_pool, pair_mismatch, invalid_token_id, // invalid_slippage, amount_too_low, minimum_lp_zero, bad_amount, backend_error. // Read-only, no submission. diff --git a/apps/amm/tests/add-liquidity.mjs b/apps/amm/tests/add-liquidity.mjs index afd777b..dbea2ea 100644 --- a/apps/amm/tests/add-liquidity.mjs +++ b/apps/amm/tests/add-liquidity.mjs @@ -252,6 +252,22 @@ test("amm liquidity: add to the A/B pool", async (app) => { console.log(` deposit: A=${filled.amountA} B=${filled.amountB}`); await saveShot(app, "add-liquidity-filled"); + // 6b. This wallet seeded the A/B pool, so it already holds LP for it. The LP-destination + // selector must therefore preselect that existing holding — the add CONSOLIDATES into + // the position rather than minting a fresh account. Wait for the preselection. + const lpSelectorId = await idByObjectName(app, "lpDestinationSelector"); + await app.waitFor( + async () => { + if ((await prop(app, lpSelectorId, "hasFunds")) !== true) + throw new Error("no existing LP holding matched yet"); + if (!(await prop(app, lpSelectorId, "selectedAccountId"))) + throw new Error("LP holding not preselected yet"); + }, + { timeout: 15000, interval: 300, description: "existing LP holding preselected" }, + ); + const lpHoldingId = await prop(app, lpSelectorId, "selectedAccountId"); + console.log(` LP consolidates into existing holding ${String(lpHoldingId).slice(0, 8)}…`); + // 7. Submit -> confirmation dialog -> confirm. const dialogId = await idByObjectName(app, "liquidityConfirmDialog"); const submitId = await idByObjectName(app, "newPositionSubmitButton"); @@ -285,9 +301,9 @@ test("amm liquidity: add to the A/B pool", async (app) => { await ignore(() => evaluate(app, dialogId, "confirm()")); } - // 8. Wait for the add to submit. Fully async — createAccountPublic (mint the fresh LP - // holding) then addLiquidity then the tx submit — so transactionId lands a few - // seconds after confirm(). + // 8. Wait for the add to submit. An existing LP holding was chosen, so there is no + // createAccountPublic step — addLiquidity submits straight into it — and transactionId + // lands a couple seconds after confirm(). try { await app.waitFor( async () => { diff --git a/apps/amm/tests/create-pool.mjs b/apps/amm/tests/create-pool.mjs index 8cb2897..09686a8 100644 --- a/apps/amm/tests/create-pool.mjs +++ b/apps/amm/tests/create-pool.mjs @@ -217,6 +217,20 @@ test("amm liquidity: create the A/C pool", async (app) => { await saveShot(app, "create-pool-filled"); console.log(` minimum deposit: A=${(await formState(app, formId)).amountA} C=${(await formState(app, formId)).amountB}`); + // 3b. A brand-new pool has no LP holding yet, so the LP-destination selector shows a fresh + // account — no matching holdings, nothing preselected — and the create mints a new LP + // account. (create-pool has no lpDefinitionId, so the selector never matches a holding.) + const lpSelectorId = await idByObjectName(app, "lpDestinationSelector"); + await app.waitFor( + async () => { + if (await prop(app, lpSelectorId, "hasFunds")) + throw new Error("unexpected existing LP holding for a brand-new pool"); + if (await prop(app, lpSelectorId, "selectedAccountId")) + throw new Error("LP destination should be unselected (fresh) for create-pool"); + }, + { timeout: 5000, interval: 300, description: "LP destination is a fresh account" }, + ); + // 4. Submit -> confirmation dialog -> confirm. const dialogId = await idByObjectName(app, "liquidityConfirmDialog"); const submitId = await idByObjectName(app, "newPositionSubmitButton"); diff --git a/modules/amm/ffi/src/api/liquidity.rs b/modules/amm/ffi/src/api/liquidity.rs index 48638b1..5bb9337 100644 --- a/modules/amm/ffi/src/api/liquidity.rs +++ b/modules/amm/ffi/src/api/liquidity.rs @@ -307,6 +307,9 @@ pub(super) fn add_liquidity_quote(request: AddLiquidityQuoteRequest) -> Result