mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-24 21:59:36 +00:00
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<String>, 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.
This commit is contained in:
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -253,6 +253,7 @@ onBackendChanged: root.refreshHoldings()
|
||||
}
|
||||
|
||||
onDraftChanged: newPositionFlow.draftChanged()
|
||||
onPairReset: newPositionFlow.resetPoolExistence()
|
||||
onRefreshRequested: newPositionFlow.refreshContext(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ✓");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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<Value, S
|
||||
return Err(String::from("same_token_pair"));
|
||||
}
|
||||
|
||||
let amount_a = positive_amount(request.amount_a_raw.as_deref())?;
|
||||
let amount_b = positive_amount(request.amount_b_raw.as_deref())?;
|
||||
// Amounts define the opening price; without them the price input drives the minimum.
|
||||
let amounts = if request.amount_a_raw.is_some() || request.amount_b_raw.is_some() {
|
||||
Some((
|
||||
positive_amount(request.amount_a_raw.as_deref())?,
|
||||
positive_amount(request.amount_b_raw.as_deref())?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let price = match amounts {
|
||||
Some((amount_a, amount_b)) => 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]);
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub amount_a_raw: Option<String>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"] = "";
|
||||
|
||||
Reference in New Issue
Block a user