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
This commit is contained in:
r4bbit
2026-08-21 15:35:27 +02:00
parent 4cb7c7e51c
commit 62d133e909
7 changed files with 106 additions and 10 deletions
@@ -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 || ""),
+18 -5
View File
@@ -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) {
+3 -1
View File
@@ -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:<code> } — no_pool, pair_mismatch, invalid_token_id,
// invalid_slippage, amount_too_low, minimum_lp_zero, bad_amount, backend_error.
// Read-only, no submission.
+19 -3
View File
@@ -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 () => {
+14
View File
@@ -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");
+3
View File
@@ -307,6 +307,9 @@ pub(super) fn add_liquidity_quote(request: AddLiquidityQuoteRequest) -> Result<V
"expectedLp": delta_lp.to_string(),
"minimumLp": minimum_lp.to_string(),
"price": price.to_string(),
// The pool's LP token definition (base58, matching the holdings' definitionId) so
// the UI can offer the wallet's existing LP holdings as the mint destination.
"lpDefinitionId": pool.liquidity_pool_id.to_string(),
}))
}
+2 -1
View File
@@ -1036,7 +1036,8 @@ LogosMap AmmModuleImpl::addLiquidityQuote(const LogosMap& request) {
if (!quoteResult.ok)
return error(quoteResult.error.empty() ? "backend_error" : quoteResult.error);
// Success: wrap { amountA, amountB, expectedLp, minimumLp, price }.
// Success: wrap { amountA, amountB, expectedLp, minimumLp, price, lpDefinitionId }.
// (lpDefinitionId is the pool's LP token so the UI can offer existing LP holdings.)
LogosMap out = quoteResult.value;
out["status"] = "ok";
out["error"] = "";