From eb98aac31f055500611c741e3865b1e27ae78de2 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:11:35 +0200 Subject: [PATCH] feat(amm): drive the create-pool liquidity preview from liquidityQuote Both liquidity branches now quote through the lean composable ops. The create path joins the add path (already on addLiquidityQuote) by reworking liquidity_quote into a dual-mode create quote and wiring the form to it via the resolvePool pool read. quoteNewPosition/amm_quote are no longer reached from the UI. FFI (modules/amm/ffi): - liquidity_quote is dual-mode: price-only (initialPriceRealRaw, no amounts) returns the minimum opening deposit via minimum_opening_pair; supplied amounts return the actual deposit with the price derived from them. Emits actual/minimum amounts, expectedLp, lockedLp and the Q64.64 price. - LiquidityQuoteRequest gains initial_price_real_raw (Option, needed only in price-only mode). Module (modules/amm/src): - liquidityQuote forwards initialPriceRealRaw to the op. UI (apps/amm/qml): - Route create-vs-add on the pool read (resolvePool -> poolExists); create quotes via liquidityQuote, assembled into the missing-pool shape the form already consumes. - poolStatus moves off the quote onto the flow's poolExists; the form derives activePool/missingPool from it. Trim the vestigial quote fields (canSubmit, requiresFreshLp, warnings, errors[], accountPreview, the "Pool" row) and drop the account-plan panel for parity with the swap view. - Fix a real bug: a pair change now resets poolExists (resetPoolExistence) so resetPairDraft re-resolves the pool like a fresh selection. Otherwise an active pool kept stale (cleared) reserves with no re-quote, and the deposit ratio-fill silently no-op'd. Tests (apps/amm/tests): - Read activePool instead of the removed poolStatus. The add test waits for the reset's active-pool quote to settle (reserves reloaded) before the ratio-fill; the create test resets the draft to clear leftover cross-run amounts and the stale submitted transactionId. --- .../components/liquidity/NewPositionForm.qml | 120 +++++++----------- .../components/liquidity/TokenAmountInput.qml | 5 + apps/amm/qml/pages/LiquidityPage.qml | 1 + apps/amm/qml/state/NewPositionFlow.qml | 70 +++++++--- apps/amm/tests/add-liquidity.mjs | 26 ++-- apps/amm/tests/create-pool.mjs | 11 +- modules/amm/ffi/src/api/liquidity.rs | 98 ++++++++++---- modules/amm/ffi/src/api/request.rs | 5 + modules/amm/ffi/tests/public_api.rs | 3 +- modules/amm/src/amm_module_impl.cpp | 9 +- 10 files changed, 214 insertions(+), 134 deletions(-) diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index a7e9db7..50077ad 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -27,6 +27,8 @@ AmmActionCard { property var holdings: [] readonly property string selectedHoldingAId: tokenAInput.selectedHoldingId readonly property string selectedHoldingBId: tokenBInput.selectedHoldingId + readonly property string selectedBalanceARaw: tokenAInput.selectedBalanceRaw + readonly property string selectedBalanceBRaw: tokenBInput.selectedBalanceRaw property string selectedTokenAId: "" property string selectedTokenBId: "" property int selectedFeeBps: 30 @@ -43,7 +45,6 @@ AmmActionCard { property string tokenResolutionError: "" property string tokenResolutionErrorSide: "" property string tokenResolutionMessage: "" - property string confirmedPoolStatus: "" property var activePoolQuote: ({}) property string headingText: qsTr("New position") property string headingDetail: "" @@ -83,9 +84,10 @@ AmmActionCard { readonly property string inverseInitialPrice: AmountMath.ratioValue(root.priceAmountB, root.priceAmountA, 12) - readonly property string poolStatus: root.effectivePoolStatus() - readonly property bool activePool: root.poolStatus === "active_pool" - readonly property bool missingPool: root.poolStatus === "missing_pool" + // Create-vs-add comes from the flow's resolvePool read (pool existence), not the quote. + // undefined ⇒ not resolved yet (neither branch shown). + readonly property bool activePool: root.flowState.poolExists === true + readonly property bool missingPool: root.flowState.poolExists === false readonly property int poolFeeBps: root.knownPoolFeeBps() readonly property bool compact: root.width < 420 readonly property bool hasPair: root.selectedTokenAId.length > 0 @@ -100,14 +102,18 @@ AmmActionCard { && root.selectedHoldingBId.length > 0) // Both deposit amounts must be present. For create the quote auto-fills them from the // opening deposit; for add the user enters one and the other ratio-fills. Without this - // the active-pool probe quote (sent on pair-select with simulated amounts) reports - // canSubmit, wrongly enabling the CTA before any amount is entered. + // an active-pool probe quote (sent on pair-select with simulated amounts) would enable + // the CTA before any amount is entered. readonly property bool hasDepositAmounts: root.amountA.length > 0 && root.amountB.length > 0 + // A successful lean quote is submittable — funding is gated below (holdings + amounts), + // not by a quote-side canSubmit flag. readonly property bool canConfirm: root.quotePayload.status === "ok" - && root.quotePayload.canSubmit === true && root.quoteMatchesPair() && root.holdingsReady && root.hasDepositAmounts + // The lean quotes don't check funding, so gate the CTA on + // the entered amounts fitting the selected holdings' balances. + && root.fundingSufficient && !root.contextLoading && !root.quoteLoading && !root.quoteStale @@ -117,11 +123,19 @@ AmmActionCard { // the chain processes it — block confirm so a stale // missing_pool quote can't submit a duplicate NewDefinition. && !(root.missingPool && root.transactionId.length > 0) + // Per-side funding check, decoupled from buildQuoteRequest/the quote: the deposit each side + // spends must fit its selected holding's balance (the lean liquidityQuote / addLiquidityQuote + // ops never compare amount to balance, so a submit would otherwise fail on an + // insufficient-balance transfer). amountA / selectedBalanceARaw are both the display token-A + // side, so no canonical reorientation is needed. + readonly property bool fundingSufficient: root.fundingError("A").length === 0 + && root.fundingError("B").length === 0 signal quoteRequested(bool immediate, var quoteRequest) signal confirmationRequested(var snapshot) signal tokenResolveRequested(string tokenId) signal draftChanged + signal pairReset signal refreshRequested readonly property int contentPadding: width >= 600 ? 24 : 16 @@ -140,7 +154,6 @@ AmmActionCard { onQuotePayloadChanged: { if (root.quoteStale) return - root.rememberPoolStatus() root.rememberActivePoolQuote() Qt.callLater(root.applyQuoteSideEffects) } @@ -558,41 +571,6 @@ AmmActionCard { ? root.quotePayload.minimumLpRaw : root.quotePayload.lockedLpRaw) } - - LabelValueRow { - label: qsTr("Pool") - value: String(root.quotePayload.poolId || "") - valueWrapAnywhere: true - } - - LogosButton { - id: accountPlanButton - text: qsTr("Account plan (%1)").arg(root.accountPreview().length) - enabled: root.accountPreview().length > 0 - property bool checked: false - implicitWidth: 150 - implicitHeight: 36 - radius: 6 - Layout.alignment: Qt.AlignLeft - onClicked: checked = !checked - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 5 - visible: accountPlanButton.checked - - Repeater { - model: root.accountPreview() - - LabelValueRow { - required property var modelData - label: qsTr("%1. %2 · %3").arg(modelData.order + 1).arg(modelData.role).arg(modelData.action) - value: modelData.accountId ? modelData.accountId : qsTr("Assigned by wallet") - valueWrapAnywhere: true - } - } - } } Rectangle { @@ -872,7 +850,6 @@ AmmActionCard { } function resetPairDraft() { - root.confirmedPoolStatus = "" root.activePoolQuote = ({}) root.amountA = "" root.amountB = "" @@ -881,6 +858,10 @@ AmmActionCard { root.minimumAmountARaw = "" root.minimumAmountBRaw = "" root.localErrors = [] + // The pair changed: the pool is unknown until re-resolved. Reset poolExists BEFORE + // requestQuote so activePool is false and the empty-amount short-circuit doesn't fire + // — the probe quote reloads the new pair's reserves/minimum like a fresh selection. + root.pairReset() root.noteDraftChanged() root.requestQuote(true) } @@ -889,30 +870,9 @@ AmmActionCard { root.draftChanged() } - function effectivePoolStatus() { - if (root.quoteStale || !root.quoteMatchesPair()) - return root.confirmedPoolStatus - var status = String(root.quotePayload.poolStatus || "") - if (status === "active_pool" || status === "missing_pool") - return status - if (root.quotePayload.code === "fee_tier_mismatch") - return "active_pool" - return root.confirmedPoolStatus - } - - function rememberPoolStatus() { - if (!root.quoteMatchesPair()) - return - var status = String(root.quotePayload.poolStatus || "") - if (status === "active_pool" || status === "missing_pool") - root.confirmedPoolStatus = status - else if (root.quotePayload.code === "fee_tier_mismatch") - root.confirmedPoolStatus = "active_pool" - } - function rememberActivePoolQuote() { if (root.quotePayload.status !== "ok" - || root.quotePayload.poolStatus !== "active_pool" + || !root.activePool || !root.quoteMatchesPair()) { return } @@ -1150,7 +1110,28 @@ AmmActionCard { return field } + // "amount_exceeds_balance" when `side` (A/B)'s entered deposit exceeds its selected holding's + // balance; "" when no holding is selected, the amount is unparsable, or it fits. Drives + // fundingSufficient (canConfirm) and the field / form error text. + function fundingError(side) { + var holdingId = side === "A" ? root.selectedHoldingAId : root.selectedHoldingBId + if (holdingId.length === 0) + return "" + var amount = side === "A" ? root.amountA : root.amountB + var decimals = side === "A" ? root.decimalsA : root.decimalsB + var balanceRaw = side === "A" ? root.selectedBalanceARaw : root.selectedBalanceBRaw + var parsed = AmountMath.parseHuman(amount, decimals) + if (parsed.ok && AmountMath.compare(parsed.raw, balanceRaw) > 0) + return "amount_exceeds_balance" + return "" + } + function fieldError(field) { + // Funding is checked independently of the quote — surface it on the offending amount field. + if (field === "amountA" && root.fundingError("A").length > 0) + return root.issueText("amount_exceeds_balance") + if (field === "amountB" && root.fundingError("B").length > 0) + return root.issueText("amount_exceeds_balance") var collections = [root.localErrors, root.currentQuoteErrors()] for (var c = 0; c < collections.length; ++c) { for (var i = 0; i < collections[c].length; ++i) { @@ -1181,6 +1162,8 @@ AmmActionCard { return root.tokenResolutionError if (root.submitError.length > 0) return root.submitError + if (root.fundingError("A").length > 0 || root.fundingError("B").length > 0) + return root.issueText("amount_exceeds_balance") var collections = [root.localErrors, root.currentQuoteErrors()] for (var c = 0; c < collections.length; ++c) { for (var i = 0; i < collections[c].length; ++i) { @@ -1437,11 +1420,6 @@ AmmActionCard { .arg(root.shortTokenName(root.tokenB)) } - function accountPreview() { - return !root.quoteStale && root.quoteMatchesPair() - ? root.quotePayload.accountPreview || [] : [] - } - function quoteError() { if (root.quoteLoading || root.quoteStale) return "" diff --git a/apps/amm/qml/components/liquidity/TokenAmountInput.qml b/apps/amm/qml/components/liquidity/TokenAmountInput.qml index a8e0982..75b13dd 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -32,6 +32,10 @@ AmmTokenAmountSurface { property string selectorObjectName: "" readonly property string selectedHoldingId: root.footerItem && root.footerItem.selectedAccountId ? String(root.footerItem.selectedAccountId) : "" + // Base-unit balance of the selected funding holding (the lean quotes don't check funding, + // so the form compares this against the entered amount). "0" when nothing is selected. + readonly property string selectedBalanceRaw: root.footerItem && root.footerItem.selectedBalanceRaw + ? String(root.footerItem.selectedBalanceRaw) : "0" footer: root.showHoldingSelector ? accountFooter : null footerHeight: root.footerItem ? root.footerItem.implicitHeight : 0 @@ -91,6 +95,7 @@ AmmTokenAmountSurface { implicitHeight: footerSelector.implicitHeight property alias selectedAccountId: footerSelector.selectedAccountId + property alias selectedBalanceRaw: footerSelector.selectedBalanceRaw ProgramAccountSelector { id: footerSelector diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 6c9d6f5..536ddf1 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -253,6 +253,7 @@ onBackendChanged: root.refreshHoldings() } onDraftChanged: newPositionFlow.draftChanged() + onPairReset: newPositionFlow.resetPoolExistence() onRefreshRequested: newPositionFlow.refreshContext(true) } } diff --git a/apps/amm/qml/state/NewPositionFlow.qml b/apps/amm/qml/state/NewPositionFlow.qml index 2b0f5ef..c56e03b 100644 --- a/apps/amm/qml/state/NewPositionFlow.qml +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -21,11 +21,17 @@ QtObject { "quoteStale": root.quoteStale, "submitting": root.submitting, "transactionId": root.transactionId, + // Create-vs-add routing signal, from the resolvePool read: true = add (pool exists), + // false = create, undefined = not resolved yet (a new pair, still resolving). + "poolExists": root.poolExists, "errorCode": root.flowErrorCode || root.contextErrorCode || root.quoteErrorCode }) property var newPositionQuote: ({}) + // Whether the selected pair's pool exists (from resolvePool); drives create-vs-add. + // undefined until the first resolve for the current pair lands. + property var poolExists: undefined property var resolvedTokenIds: [] property int contextSerial: 0 property int quoteSerial: 0 @@ -171,6 +177,7 @@ QtObject { if (serial !== root.quoteSerial) return if (pool && pool.exists) { + root.poolExists = true root.requestAddQuote(serial, built, pool) return } @@ -181,8 +188,12 @@ QtObject { // would hide the backend failure and enable the wrong flow). var poolError = pool ? String(pool.error || "") : "" if (poolError.length === 0 || poolError === "no_pool") { + root.poolExists = false root.requestCreateQuote(serial, built) } else { + // Hard failure: leave poolExists unresolved so the form doesn't drop into + // create mode on a backend/config error. + root.poolExists = undefined root.quoteLoading = false root.quoteStale = false root.quoteErrorCode = "" @@ -228,20 +239,22 @@ QtObject { }) } - // Create-pool preview still on the legacy quoteNewPosition — migrated to createPoolQuote - // (the create counterpart of addLiquidityQuote) in a later step. + // Create-pool preview via the lean liquidityQuote (dual-mode: price-only returns the + // minimum opening deposit; supplied amounts return the actual). Assembled into the + // missing-pool shape the form consumes. built.request carries the price (+ amounts once + // the user edits past the minimum), so it can be forwarded as-is. function requestCreateQuote(serial, built) { - root.runtime.watch(root.backend.quoteNewPosition(built.request), + root.runtime.watch(root.backend.liquidityQuote(built.request), function(quote) { if (serial !== root.quoteSerial) return root.quoteLoading = false root.quoteStale = false root.quoteErrorCode = "" - if (!quote || !quote.status) - root.newPositionQuote = root.quoteError("backend_error") + if (quote && quote.status === "ok") + root.newPositionQuote = root.assembleCreateQuote(built, quote) else - root.newPositionQuote = quote + root.newPositionQuote = root.quoteError((quote && quote.error) || "backend_error") }, function(error) { if (serial !== root.quoteSerial) @@ -252,14 +265,30 @@ QtObject { }) } + // Maps liquidityQuote into the quote shape NewPositionForm reads for a missing pool. + // Amounts are in the request's (canonical) order, matching the form's displayIsCanonical + // mapping; minimumAmount* is what the form validates the entered deposit against. + function assembleCreateQuote(built, quote) { + return { + "status": "ok", + "tokenAId": built.request.tokenAId, + "tokenBId": built.request.tokenBId, + "actualAmountARaw": String(quote.actualAmountARaw || "0"), + "actualAmountBRaw": String(quote.actualAmountBRaw || "0"), + "minimumAmountARaw": String(quote.minimumAmountARaw || "0"), + "minimumAmountBRaw": String(quote.minimumAmountBRaw || "0"), + "expectedLpRaw": String(quote.expectedLpRaw || "0"), + "lockedLpRaw": String(quote.lockedLpRaw || "0"), + "initialPriceRealRaw": String(quote.initialPriceRealRaw || "0") + } + } + // Maps addLiquidityQuote + the pool read into the quote shape NewPositionForm reads for an // active pool. Amounts/reserves are in the request's (canonical) order, matching the form's // displayIsCanonical mapping; minimumLpRaw is the slippage floor the module computed. function assembleAddQuote(built, pool, quote) { return { "status": "ok", - "poolStatus": "active_pool", - "canSubmit": true, "tokenAId": built.request.tokenAId, "tokenBId": built.request.tokenBId, "actualAmountARaw": String(quote.amountARaw || "0"), @@ -269,11 +298,7 @@ QtObject { "reserveARaw": String(pool.reserveA || "0"), "reserveBRaw": String(pool.reserveB || "0"), "poolFeeBps": pool.feeBps, - "requiresFreshLp": true, - "initialPriceRealRaw": String(quote.priceRaw || "0"), - "errors": [], - "warnings": [], - "accountPreview": [] + "initialPriceRealRaw": String(quote.priceRaw || "0") } } @@ -291,8 +316,8 @@ QtObject { // Route by pool state: creation (initialPriceRealRaw is set only on the missing-pool // path) goes through createPool; the active-pool branch through addLiquidity. Both // mint a fresh LP holding then submit via the lean module ops (hex ids, - // caller-provided accounts). Add quoting is now on addLiquidityQuote; create quoting - // stays on the legacy quoteNewPosition until createPoolQuote is wired. + // caller-provided accounts). Quoting for both branches is now on the lean ops + // (liquidityQuote / addLiquidityQuote), routed by resolvePool in requestQuoteNow. if (snapshot.request.initialPriceRealRaw !== undefined) root.createPool(snapshot) else @@ -429,6 +454,15 @@ QtObject { root.quoteErrorCode = "" } + // The selected pair changed, so the pool it maps to is unknown until the next + // resolvePool. Clearing poolExists drops both activePool/missingPool to false, which + // keeps requestQuote from short-circuiting an active pool's empty-amount probe and lets + // buildQuoteRequest emit the price+probe request a fresh selection would — reloading the + // reserves (add) or the opening minimum (create) for the new pair. + function resetPoolExistence() { + root.poolExists = undefined + } + function invalidateQuote() { ++root.quoteSerial root.quoteDebounce.stop() @@ -447,16 +481,12 @@ QtObject { function quoteError(code) { return { "status": "error", - "canSubmit": false, "code": code, - "poolStatus": "unavailable_pool", "errors": [{ "code": code, "blockingFields": [], "details": ({}) - }], - "warnings": [], - "accountPreview": [] + }] } } } diff --git a/apps/amm/tests/add-liquidity.mjs b/apps/amm/tests/add-liquidity.mjs index a6ec48f..e0b5d57 100644 --- a/apps/amm/tests/add-liquidity.mjs +++ b/apps/amm/tests/add-liquidity.mjs @@ -60,8 +60,9 @@ async function formState(app, formId) { const props = (await app.getProperties(formId)).properties || []; const get = (n) => { const p = props.find((x) => x.name === n); return p ? p.value : undefined; }; return { - poolStatus: get("poolStatus"), activePool: get("activePool"), + quoteStale: get("quoteStale"), + quoteLoading: get("quoteLoading"), canConfirm: get("canConfirm"), amountA: get("amountA"), amountB: get("amountB"), @@ -197,8 +198,8 @@ test("amm liquidity: add to the A/B pool", async (app) => { await app.waitFor( async () => { const s = await formState(app, formId); - if (s.poolStatus !== "active_pool") - throw new Error(`pool not active yet (status=${s.poolStatus})`); + if (s.activePool !== true) + throw new Error(`pool not active yet (activePool=${s.activePool})`); }, { timeout: 20000, interval: 500, description: "active-pool quote" }, ); @@ -207,23 +208,26 @@ test("amm liquidity: add to the A/B pool", async (app) => { await selectAccount(app, "newPositionAccountSelectorA"); await selectAccount(app, "newPositionAccountSelectorB"); - // 5. The CTA must be DISABLED when no deposit amounts are entered — even though the - // pool is active and the pair's probe quote reports canSubmit on simulated amounts. - // resetPairDraft() clears the amount fields and re-fires that probe quote, reaching - // this exact state deterministically regardless of any prior form state (the live app - // window persists across runs, so the fields may carry leftover amounts). + // 5. The CTA must be DISABLED when no deposit amounts are entered — even though the pool is + // active. canConfirm gates on the entered amounts (+ holdings), not on any quote-side + // flag. resetPairDraft() clears the amount fields (the live app window persists across + // runs, so they may carry leftover amounts) and, because the pair is treated as changed, + // re-resolves the pool. Wait for that active-pool quote to FULLY settle (activePool back + // to true and the quote no longer stale/loading) so the reserves are reloaded before the + // step-6 ratio-fill needs them. At that point amounts are still empty → canConfirm false. await evaluate(app, formId, "resetPairDraft()"); await app.waitFor( async () => { const s = await formState(app, formId); - if (s.poolStatus !== "active_pool") - throw new Error(`probe quote not back yet (status=${s.poolStatus})`); + if (s.activePool !== true || s.quoteStale === true || s.quoteLoading === true) + throw new Error(`reset quote not settled (activePool=${s.activePool} ` + + `stale=${s.quoteStale} loading=${s.quoteLoading})`); if (s.amountA || s.amountB) throw new Error(`deposit amounts not cleared (A=${s.amountA} B=${s.amountB})`); if (s.canConfirm) throw new Error("Add-liquidity CTA is enabled with no deposit amounts entered"); }, - { timeout: 20000, interval: 500, description: "CTA disabled with no amounts" }, + { timeout: 20000, interval: 500, description: "CTA disabled, pool re-resolved" }, ); console.log(" CTA correctly disabled with no amounts entered ✓"); diff --git a/apps/amm/tests/create-pool.mjs b/apps/amm/tests/create-pool.mjs index 1d55663..93783dd 100644 --- a/apps/amm/tests/create-pool.mjs +++ b/apps/amm/tests/create-pool.mjs @@ -56,7 +56,7 @@ async function formState(app, formId) { const props = (await app.getProperties(formId)).properties || []; const get = (n) => { const p = props.find((x) => x.name === n); return p ? p.value : undefined; }; return { - poolStatus: get("poolStatus"), + activePool: get("activePool"), missingPool: get("missingPool"), canConfirm: get("canConfirm"), amountA: get("amountA"), @@ -186,7 +186,7 @@ test("amm liquidity: create the A/C pool", async (app) => { await app.waitFor( async () => { const s = await formState(app, formId); - if (s.poolStatus === "active_pool") + if (s.activePool === true) throw new Error("A/C pool already exists — reset the testnet (only A/B should be seeded)"); if (!s.missingPool) throw new Error("pool status not resolved yet"); }, @@ -194,6 +194,13 @@ test("amm liquidity: create the A/C pool", async (app) => { ); await selectAccount(app, "newPositionAccountSelectorA"); await selectAccount(app, "newPositionAccountSelectorB"); + // The live app window persists across runs, so the form may carry leftover deposit + // amounts and a stale "Position submitted" transactionId from a prior create — both + // block a clean run (mismatched amounts keep canConfirm false; a stale txId would make + // step 5's "submitted" wait pass instantly). resetPairDraft() clears the amounts/price + // and re-quotes (price-only), so applyQuoteSideEffects re-fills the fresh minimum + // deposit, and its draftChanged() clears the stale transactionId. Holdings are kept. + await evaluate(app, formId, "resetPairDraft()"); await app.waitFor( async () => { const s = await formState(app, formId); diff --git a/modules/amm/ffi/src/api/liquidity.rs b/modules/amm/ffi/src/api/liquidity.rs index 61010d0..83f310e 100644 --- a/modules/amm/ffi/src/api/liquidity.rs +++ b/modules/amm/ffi/src/api/liquidity.rs @@ -20,6 +20,7 @@ use serde_json::{json, Value}; use super::{ pair::{derive_pair, is_canonical_pair}, + quote::minimum_opening_pair, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, }; @@ -83,17 +84,19 @@ fn plan_response( }) } -/// Prices a create-pool deposit: the LP the creator receives and the opening price. +/// Prices a create-pool deposit — dual mode, matching the legacy create quote (minus its +/// funding/account-preview machinery). Pure: no chain reads, no fee (the fee isn't part of +/// the pool PDA nor the pricing). /// -/// Pure — no chain reads. The fee tier is not needed: it is not part of the pool PDA -/// (`compute_pool_pda_seed` hashes only the pair) and does not enter the pricing — -/// only the two deposit amounts do. `expected_lp = floor(sqrt(a*b)) - MINIMUM_LIQUIDITY` -/// (the post-permanent-lock remainder the guest mints to the creator); `initialPriceRaw` -/// is the `Q64.64` display price (token B per token A, in the caller's order). The LP -/// figure is orientation-independent (the product is symmetric); the price follows the -/// display order. Errors are stable short codes: `same_token_pair`, `amount_required`, -/// `invalid_raw_amount`, `amount_must_be_positive`, `amount_too_low` (deposits too small -/// to clear the locked minimum — the pool can't open). +/// The opening price *is* the deposit ratio. With **amounts** supplied, the op uses them and +/// derives the price (`spot_price_q64_64`); **price-only** (no amounts), it takes +/// `initial_price_real_raw` (Q64.64, canonical) and uses `minimum_opening_pair` — the smallest +/// deposit at that price that clears the permanently-locked `MINIMUM_LIQUIDITY`. Either way it +/// also returns that `minimum*` pair (the form validates entered amounts against it) and +/// `expected_lp = floor(sqrt(a·b)) - MINIMUM_LIQUIDITY` (LP is orientation-independent — the +/// product is symmetric). Errors: `same_token_pair`, `amount_required` (price-only without a +/// price), `invalid_raw_amount`, `amount_must_be_positive`, `amount_too_low` (deposits too +/// small to clear the locked minimum). pub(super) fn liquidity_quote(request: LiquidityQuoteRequest) -> Result { let token_a = account_id_from_hex(&request.token_a_id, "token A id")?; let token_b = account_id_from_hex(&request.token_b_id, "token B id")?; @@ -101,25 +104,38 @@ pub(super) fn liquidity_quote(request: LiquidityQuoteRequest) -> Result spot_price_q64_64(amount_a, amount_b), + None => positive_amount(request.initial_price_real_raw.as_deref())?, + }; + let (minimum_a, minimum_b) = minimum_opening_pair(price)?; + let (actual_a, actual_b) = amounts.unwrap_or((minimum_a, minimum_b)); - // LP math (shared with the guest's new_definition via amm_core): the initial LP - // must clear the permanently-locked minimum before the creator receives any. - let initial_lp = isqrt_product(amount_a, amount_b); + // LP math (shared with the guest's new_definition via amm_core): the initial LP must + // clear the permanently-locked minimum before the creator receives any. + let initial_lp = isqrt_product(actual_a, actual_b); let expected_lp = initial_lp .checked_sub(MINIMUM_LIQUIDITY) .filter(|user_lp| *user_lp > 0) .ok_or("amount_too_low")?; - // Display-order price: token B per token A (the caller's orientation). - let initial_price = spot_price_q64_64(amount_a, amount_b); Ok(json!({ - "amountARaw": amount_a.to_string(), - "amountBRaw": amount_b.to_string(), + "actualAmountARaw": actual_a.to_string(), + "actualAmountBRaw": actual_b.to_string(), + "minimumAmountARaw": minimum_a.to_string(), + "minimumAmountBRaw": minimum_b.to_string(), "expectedLpRaw": expected_lp.to_string(), "lockedLpRaw": MINIMUM_LIQUIDITY.to_string(), - "initialPriceRaw": initial_price.to_string(), + "initialPriceRealRaw": price.to_string(), })) } @@ -393,6 +409,7 @@ mod tests { LiquidityQuoteRequest { token_a_id: account_id_hex(token_a), token_b_id: account_id_hex(token_b), + initial_price_real_raw: None, amount_a_raw: Some(String::from("1000000")), amount_b_raw: Some(String::from("4000000")), } @@ -423,13 +440,14 @@ mod tests { } #[test] - fn create_quote_prices_the_opening() { + fn create_quote_prices_supplied_amounts() { let token_a = AccountId::new([0xAA; 32]); let token_b = AccountId::new([0xBB; 32]); let value = liquidity_quote(quote_request(token_a, token_b)).unwrap(); - assert_eq!(value["amountARaw"], "1000000"); - assert_eq!(value["amountBRaw"], "4000000"); + // Amounts supplied ⇒ actual == the amounts; the price is derived from them. + assert_eq!(value["actualAmountARaw"], "1000000"); + assert_eq!(value["actualAmountBRaw"], "4000000"); assert_eq!(value["lockedLpRaw"], MINIMUM_LIQUIDITY.to_string()); // initial_lp = isqrt(1_000_000 * 4_000_000) = 2_000_000; creator LP = minus lock. let initial_lp = isqrt_product(1_000_000, 4_000_000); @@ -437,16 +455,42 @@ mod tests { value["expectedLpRaw"], (initial_lp - MINIMUM_LIQUIDITY).to_string() ); - assert_eq!( - value["initialPriceRaw"], - spot_price_q64_64(1_000_000, 4_000_000).to_string() - ); + let price = spot_price_q64_64(1_000_000, 4_000_000); + assert_eq!(value["initialPriceRealRaw"], price.to_string()); + // The minimum opening deposit for that price is echoed for the form to validate against. + let (min_a, min_b) = minimum_opening_pair(price).unwrap(); + assert_eq!(value["minimumAmountARaw"], min_a.to_string()); + assert_eq!(value["minimumAmountBRaw"], min_b.to_string()); // Lean preview — no commitment / status / submittability fields. assert!(value.get("quoteHash").is_none()); assert!(value.get("canSubmit").is_none()); assert!(value.get("poolStatus").is_none()); } + #[test] + fn create_quote_price_only_returns_the_minimum_opening_deposit() { + let token_a = AccountId::new([0xAA; 32]); + let token_b = AccountId::new([0xBB; 32]); + let price = spot_price_q64_64(1_000_000, 4_000_000); + let (min_a, min_b) = minimum_opening_pair(price).unwrap(); + + let value = liquidity_quote(LiquidityQuoteRequest { + token_a_id: account_id_hex(token_a), + token_b_id: account_id_hex(token_b), + initial_price_real_raw: Some(price.to_string()), + amount_a_raw: None, + amount_b_raw: None, + }) + .unwrap(); + + // Price-only ⇒ the actual deposit is the minimum opening pair for that price. + assert_eq!(value["actualAmountARaw"], min_a.to_string()); + assert_eq!(value["actualAmountBRaw"], min_b.to_string()); + assert_eq!(value["minimumAmountARaw"], min_a.to_string()); + assert_eq!(value["minimumAmountBRaw"], min_b.to_string()); + assert_eq!(value["initialPriceRealRaw"], price.to_string()); + } + #[test] fn create_quote_lp_is_orientation_independent() { let token_a = AccountId::new([0xAA; 32]); diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index fcf45d1..0cfa543 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -140,6 +140,11 @@ pub struct SwapExactOutPlanRequest { pub struct LiquidityQuoteRequest { pub token_a_id: String, pub token_b_id: String, + /// The opening price as a `Q64.64` fixed-point value (token B per token A, canonical + /// order). Required only in the price-only mode (no `amount_*_raw`), where it drives the + /// minimum opening deposit; when amounts are supplied the op derives the price from them. + #[serde(default)] + pub initial_price_real_raw: Option, #[serde(default)] pub amount_a_raw: Option, #[serde(default)] diff --git a/modules/amm/ffi/tests/public_api.rs b/modules/amm/ffi/tests/public_api.rs index 90b1a58..2118286 100644 --- a/modules/amm/ffi/tests/public_api.rs +++ b/modules/amm/ffi/tests/public_api.rs @@ -22,11 +22,12 @@ fn create_pool_surface_is_reexported_from_crate_root() { let quote = liquidity_quote(LiquidityQuoteRequest { token_a_id: "11".repeat(32), token_b_id: "22".repeat(32), + initial_price_real_raw: None, // amounts supplied ⇒ the op derives the price amount_a_raw: Some("1000000".into()), amount_b_raw: Some("4000000".into()), }) .expect("a valid pure create-pool quote should succeed"); - assert_eq!(quote["amountARaw"], "1000000"); + assert_eq!(quote["actualAmountARaw"], "1000000"); let _plan: fn(CreatePoolPlanRequest) -> AmmResult = create_pool_plan; } diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index b461efa..cc362ef 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -753,6 +753,11 @@ LogosMap AmmModuleImpl::liquidityQuote(const LogosMap& request) { {"tokenAId", token_a}, {"tokenBId", token_b}, }; + // initialPriceRealRaw is the Q64.64 opening price; used when no amounts are supplied + // (price-only ⇒ the op returns the minimum opening deposit). Left out if absent. + std::string price_decimal; + if (jsonAmountToDecimal(request.value("initialPriceRealRaw", json()), price_decimal)) + quoteRequest["initialPriceRealRaw"] = price_decimal; if (request.contains("amountARaw")) { std::string amount_a_decimal; if (!jsonAmountToDecimal(request.at("amountARaw"), amount_a_decimal)) @@ -770,8 +775,8 @@ LogosMap AmmModuleImpl::liquidityQuote(const LogosMap& request) { if (!quoteResult.ok) return error(quoteResult.error.empty() ? "backend_error" : quoteResult.error); - // Success: wrap { amountARaw, amountBRaw, expectedLpRaw, lockedLpRaw, - // initialPriceRaw } in the standard envelope. + // Success: wrap { actualAmountARaw, actualAmountBRaw, minimumAmountARaw, + // minimumAmountBRaw, expectedLpRaw, lockedLpRaw, initialPriceRealRaw } in the envelope. LogosMap out = quoteResult.value; out["status"] = "ok"; out["error"] = "";