mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(apps/amm): drive the exact-output swap preview from swapExactOutQuote
Wire the Buy direction of the swap card to the module's server-side
swapExactOutQuote, mirroring the exact-input path. AmmUiBackend gains a
swapExactOutQuote(tokenIn, tokenOut, amountOutDecimal, slippageBps) slot
returning { requiredInRaw, maxInRaw, priceImpactBps } (read-only).
Editing the Buy amount now debounces a swapExactOutQuote call and sources the
required input (shown in the Sell field), the price impact, and the slippage
ceiling from it — the exact figures come straight from the quote's raw integer
strings, so the preview matches execution and no reserve orientation happens
client-side. A retyped amount invalidates the quote up front.
SwapSummary's last row is generalised from a hardcoded "Min received" to a
direction-aware bound (boundLabel/boundText): "Min received" (min out) for
exact input, "Maximum sent" (max in) for exact output. SwapConfirmationSummary
is updated for the renamed property.
The Buy field remains preview-only — canSubmit is still sell-only, pending the
exact-output submit wiring. The now-orphaned DummySwapState pricing helpers are
left for a follow-up cleanup.
This commit is contained in:
@@ -44,6 +44,13 @@ Rectangle {
|
||||
property string quoteMinReceivedRaw: "0"
|
||||
property int quotePriceImpactBps: 0
|
||||
|
||||
// ── Exact-output quote (backend.swapExactOutQuote) ──────────────────────
|
||||
property bool quoteOutLoading: false
|
||||
property string quoteOutError: ""
|
||||
property string quoteRequiredInRaw: "0"
|
||||
property string quoteMaxInRaw: "0"
|
||||
property int quoteOutPriceImpactBps: 0
|
||||
|
||||
// ── Swap submission (backend.swapExactInput) ────────────────────────────
|
||||
property bool swapInProgress: false
|
||||
property string swapError: ""
|
||||
@@ -88,11 +95,12 @@ Rectangle {
|
||||
resolveDebounce.stop()
|
||||
}
|
||||
|
||||
onSellTokenChanged: { root.requestResolve(); root.requestQuoteIn() }
|
||||
onBuyTokenChanged: { root.requestResolve(); root.requestQuoteIn() }
|
||||
onSellTokenChanged: { root.requestResolve(); root.requestQuoteIn(); root.requestQuoteOut() }
|
||||
onBuyTokenChanged: { root.requestResolve(); root.requestQuoteIn(); root.requestQuoteOut() }
|
||||
onSellInputChanged: root.requestQuoteIn()
|
||||
onEditingSideChanged: root.requestQuoteIn()
|
||||
onSlippageTolerancePercentChanged: root.requestQuoteIn()
|
||||
onBuyInputChanged: root.requestQuoteOut()
|
||||
onEditingSideChanged: { root.requestQuoteIn(); root.requestQuoteOut() }
|
||||
onSlippageTolerancePercentChanged: { root.requestQuoteIn(); root.requestQuoteOut() }
|
||||
|
||||
function doResolvePool() {
|
||||
if (!root.backend || !root.sellToken || !root.buyToken)
|
||||
@@ -224,6 +232,100 @@ Rectangle {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Exact-output quote ─────────────────────────────────────────────────────
|
||||
Timer {
|
||||
id: quoteOutDebounce
|
||||
interval: 350
|
||||
repeat: false
|
||||
onTriggered: root.doQuoteOut()
|
||||
}
|
||||
|
||||
function resetQuoteOut() {
|
||||
root.quoteRequiredInRaw = "0"
|
||||
root.quoteMaxInRaw = "0"
|
||||
root.quoteOutPriceImpactBps = 0
|
||||
}
|
||||
|
||||
// The Buy field is free-form (not digitsOnly like Sell), so its text is
|
||||
// normalized to a base-units integer before quoting: trim whitespace and
|
||||
// accept only a positive run of digits. Decimals / exponents / signs / empty
|
||||
// yield "" (invalid), so the backend call is skipped rather than forwarding an
|
||||
// amount that would come back as a confusing quote failure.
|
||||
function normalizedAmountOut() {
|
||||
var s = String(root.buyInput).trim()
|
||||
return (/^\d+$/.test(s) && /[1-9]/.test(s)) ? s : ""
|
||||
}
|
||||
|
||||
function requestQuoteOut() {
|
||||
root.quoteOutError = ""
|
||||
// Mirror of requestQuoteIn for the Buy direction: price the input needed
|
||||
// for the typed output. Invalidate the previous quote up front so a stale
|
||||
// required-in isn't shown while the re-quote is pending. Invalid input
|
||||
// (see normalizedAmountOut) takes the else branch, clearing the loading
|
||||
// flag so it can't get stuck.
|
||||
if (root.editingSide === "buy" && root.sellToken && root.buyToken
|
||||
&& root.normalizedAmountOut() !== "") {
|
||||
root.resetQuoteOut()
|
||||
root.quoteOutLoading = true
|
||||
quoteOutDebounce.restart()
|
||||
} else {
|
||||
quoteOutDebounce.stop()
|
||||
root.quoteOutLoading = false
|
||||
root.resetQuoteOut()
|
||||
}
|
||||
}
|
||||
|
||||
function doQuoteOut() {
|
||||
var amountOut = root.normalizedAmountOut()
|
||||
if (!root.backend || root.editingSide !== "buy"
|
||||
|| !root.sellToken || !root.buyToken || amountOut === "") {
|
||||
root.quoteOutLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
var reqSell = root.sellToken.definitionId
|
||||
var reqBuy = root.buyToken.definitionId
|
||||
// Staleness is keyed on the raw field text (a further edit re-quotes),
|
||||
// while the backend gets the normalized base-units amount.
|
||||
var reqInput = root.buyInput
|
||||
function isStale() {
|
||||
return root.editingSide !== "buy"
|
||||
|| !root.sellToken || !root.buyToken
|
||||
|| root.sellToken.definitionId !== reqSell
|
||||
|| root.buyToken.definitionId !== reqBuy
|
||||
|| root.buyInput !== reqInput
|
||||
}
|
||||
|
||||
var slippageBps = Math.round(root.slippageTolerancePercent * 100)
|
||||
root.quoteOutLoading = true
|
||||
// tokenIn is the sold token (sell), tokenOut is the bought token (buy).
|
||||
logos.watch(root.backend.swapExactOutQuote(reqSell, reqBuy, amountOut, slippageBps),
|
||||
function (quote) {
|
||||
if (isStale())
|
||||
return
|
||||
root.quoteOutLoading = false
|
||||
if (quote && quote.status === "ok") {
|
||||
root.quoteRequiredInRaw = quote.requiredInRaw || "0"
|
||||
root.quoteMaxInRaw = quote.maxInRaw || "0"
|
||||
root.quoteOutPriceImpactBps = quote.priceImpactBps || 0
|
||||
root.quoteOutError = ""
|
||||
} else {
|
||||
root.resetQuoteOut()
|
||||
// no_pool is surfaced via the pool status text, not as an error.
|
||||
var code = (quote && quote.error) || "backend_error"
|
||||
root.quoteOutError = code === "no_pool" ? "" : code
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
if (isStale())
|
||||
return
|
||||
console.warn("swapExactOutQuote error:", error)
|
||||
root.quoteOutLoading = false
|
||||
root.resetQuoteOut()
|
||||
root.quoteOutError = String(error)
|
||||
})
|
||||
}
|
||||
|
||||
// JS doubles lose precision far below u128 range; these are only used to
|
||||
// drive the *estimate* (expected output / min received / price impact),
|
||||
// never the actual swap amount — the sell amount sent to the backend is
|
||||
@@ -245,25 +347,34 @@ Rectangle {
|
||||
return isNaN(amt) || amt < 0 ? 0 : amt
|
||||
}
|
||||
|
||||
// The computed side comes from the server quote: exact-input (Sell) yields the
|
||||
// expected output, exact-output (Buy) yields the required input. Number() may
|
||||
// lose precision on large base-unit values, so these drive gating only — the
|
||||
// exact figures shown and submitted come from the raw quote strings directly.
|
||||
readonly property real parsedSellAmount: editingSide === "sell"
|
||||
? parsedSellInput
|
||||
: swapState.amountInFor(parsedBuyInput, sellReserveNum, buyReserveNum)
|
||||
: (Number(root.quoteRequiredInRaw) || 0)
|
||||
|
||||
// Exact-input (Sell) expected output comes from the server quote; the Buy
|
||||
// direction still estimates locally. Number() may lose precision on large
|
||||
// base-unit values, so this drives gating only — the exact figures shown and
|
||||
// submitted come from quoteExpectedOutRaw / quoteMinReceivedRaw directly.
|
||||
readonly property real parsedBuyAmount: editingSide === "buy"
|
||||
? parsedBuyInput
|
||||
: (Number(root.quoteExpectedOutRaw) || 0)
|
||||
|
||||
readonly property real feeAmount: swapState.feeAmount(parsedSellAmount)
|
||||
readonly property real minReceivedAmount: editingSide === "sell"
|
||||
? (Number(root.quoteMinReceivedRaw) || 0)
|
||||
: swapState.minReceived(parsedBuyAmount, slippageTolerancePercent)
|
||||
|
||||
// Slippage bound: exact input floors the received amount (Min received), exact
|
||||
// output caps the spent amount (Maximum sent). Both come from the quote.
|
||||
readonly property string boundLabel: editingSide === "sell" ? qsTr("Min received") : qsTr("Maximum sent")
|
||||
// The quote's exact-integer bound, verbatim (no Number()/double round-trip,
|
||||
// which would lose precision on large u128 values and diverge from execution):
|
||||
// min received (exact input) or max sent (exact output).
|
||||
readonly property string boundRaw: editingSide === "sell" ? root.quoteMinReceivedRaw : root.quoteMaxInRaw
|
||||
readonly property string boundSymbol: editingSide === "sell"
|
||||
? (buyToken ? buyToken.symbol : "")
|
||||
: (sellToken ? sellToken.symbol : "")
|
||||
|
||||
readonly property real priceImpactPercent: editingSide === "sell"
|
||||
? root.quotePriceImpactBps / 100
|
||||
: swapState.priceImpactPercent(parsedSellAmount, parsedBuyAmount, sellReserveNum, buyReserveNum)
|
||||
: root.quoteOutPriceImpactBps / 100
|
||||
|
||||
readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output (preview only)") : qsTr("Exact input")
|
||||
|
||||
@@ -306,6 +417,7 @@ Rectangle {
|
||||
if (root.poolError.length > 0) return root.poolError
|
||||
if (root.poolResolved && !root.poolExists) return qsTr("No pool / no liquidity for this pair.")
|
||||
if (root.quoteInError.length > 0) return qsTr("Quote failed: %1").arg(root.quoteInError)
|
||||
if (root.quoteOutError.length > 0) return qsTr("Quote failed: %1").arg(root.quoteOutError)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -335,12 +447,13 @@ Rectangle {
|
||||
return digits + "0".repeat(Math.max(0, exponent - (match[2] ? match[2].length : 0)))
|
||||
}
|
||||
|
||||
// The computed side is shown as the quote's exact-integer string verbatim (no
|
||||
// double round-trip): the required input in the Buy direction, the expected
|
||||
// output in the Sell direction.
|
||||
readonly property string sellDisplay: editingSide === "sell"
|
||||
? sellInput
|
||||
: (parsedSellAmount > 0 ? formatBaseUnits(parsedSellAmount) : "")
|
||||
: ((root.quoteRequiredInRaw && root.quoteRequiredInRaw !== "0") ? root.quoteRequiredInRaw : "")
|
||||
|
||||
// Sell direction shows the quote's exact-integer expected output verbatim
|
||||
// (no double round-trip); Buy direction still renders the local estimate.
|
||||
readonly property string buyDisplay: editingSide === "buy"
|
||||
? buyInput
|
||||
: ((root.quoteExpectedOutRaw && root.quoteExpectedOutRaw !== "0") ? root.quoteExpectedOutRaw : "")
|
||||
@@ -538,7 +651,8 @@ Rectangle {
|
||||
feeText: swapState.formatTokenAmount(root.feeAmount, root.sellToken ? root.sellToken.symbol : "")
|
||||
priceImpactText: swapState.formatPercent(root.priceImpactPercent)
|
||||
priceImpactPercent: root.priceImpactPercent
|
||||
minReceivedText: swapState.formatTokenAmount(root.minReceivedAmount, root.buyToken ? root.buyToken.symbol : "")
|
||||
boundLabel: root.boundLabel
|
||||
boundText: root.boundSymbol ? (root.boundRaw + " " + root.boundSymbol) : root.boundRaw
|
||||
}
|
||||
|
||||
SlippageToleranceControl {
|
||||
|
||||
@@ -81,7 +81,7 @@ ColumnLayout {
|
||||
priceImpactText: root.snapshot.priceImpactPercent || ""
|
||||
priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0
|
||||
slippageText: root.snapshot.slippageTolerance || ""
|
||||
minReceivedText: qsTr("%1 %2")
|
||||
boundText: qsTr("%1 %2")
|
||||
.arg(root.snapshot.minReceived || "")
|
||||
.arg(root.snapshot.buyToken || "")
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ Item {
|
||||
property string priceImpactText: ""
|
||||
property real priceImpactPercent: 0
|
||||
property string slippageText: ""
|
||||
property string minReceivedText: ""
|
||||
// The slippage-bound row adapts to direction: "Min received" (exact input) or
|
||||
// "Maximum sent" (exact output).
|
||||
property string boundLabel: qsTr("Min received")
|
||||
property string boundText: ""
|
||||
|
||||
readonly property color priceImpactColor: {
|
||||
if (root.priceImpactPercent > 5) return "#F08A76";
|
||||
@@ -130,7 +133,7 @@ Item {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 12
|
||||
text: qsTr("Min received")
|
||||
text: root.boundLabel
|
||||
}
|
||||
|
||||
Text {
|
||||
@@ -139,7 +142,7 @@ Item {
|
||||
color: root.theme.colors.textPrimary
|
||||
font.bold: true
|
||||
font.pixelSize: 12
|
||||
text: root.minReceivedText
|
||||
text: root.boundText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,15 @@ QVariantMap AmmUiBackend::swapExactInQuote(QString tokenInHex, QString tokenOutH
|
||||
tokenInHex, tokenOutHex, amountInDecimal, slippageBps);
|
||||
}
|
||||
|
||||
QVariantMap AmmUiBackend::swapExactOutQuote(QString tokenInHex, QString tokenOutHex,
|
||||
QString amountOutDecimal, int slippageBps)
|
||||
{
|
||||
// Read-only preview — the exact-output counterpart of swapExactInQuote:
|
||||
// prices the input required for a desired output and its slippage ceiling.
|
||||
return m_logos->amm_module.swapExactOutQuote(
|
||||
tokenInHex, tokenOutHex, amountOutDecimal, slippageBps);
|
||||
}
|
||||
|
||||
QVariantList AmmUiBackend::tokenList()
|
||||
{
|
||||
return m_logos->amm_module.tokenList();
|
||||
|
||||
@@ -63,6 +63,8 @@ public slots:
|
||||
QString minOutDecimal, QString deadlineDecimal) override;
|
||||
QVariantMap swapExactInQuote(QString tokenInHex, QString tokenOutHex,
|
||||
QString amountInDecimal, int slippageBps) override;
|
||||
QVariantMap swapExactOutQuote(QString tokenInHex, QString tokenOutHex,
|
||||
QString amountOutDecimal, int slippageBps) override;
|
||||
// Reads the token list from TOKENS_CONFIG (via the module) so the Swap UI's
|
||||
// token picker is config-driven instead of hardcoded.
|
||||
QVariantList tokenList() override;
|
||||
|
||||
@@ -72,6 +72,15 @@ class AmmUiBackend
|
||||
// config_missing, bad_amount, invalid_slippage (slippageBps out of range),
|
||||
// backend_error. Read-only, no submission.
|
||||
SLOT(QVariantMap swapExactInQuote(QString tokenInHex, QString tokenOutHex, QString amountInDecimal, int slippageBps))
|
||||
// Server-side SwapExactOutput preview for (tokenInHex, tokenOutHex): reads the
|
||||
// pool and returns { status:"ok", error:"", requiredInRaw, maxInRaw,
|
||||
// priceImpactBps } — the input needed for the desired output and the slippage
|
||||
// ceiling on it — oriented and priced via the shared on-chain formula.
|
||||
// amountOutDecimal is a decimal-string base-unit amount; slippageBps is basis
|
||||
// points. On failure { status:"error", error:<code> } — no_pool,
|
||||
// output_exceeds_liquidity, config_missing, bad_amount, backend_error.
|
||||
// Read-only, no submission.
|
||||
SLOT(QVariantMap swapExactOutQuote(QString tokenInHex, QString tokenOutHex, QString amountOutDecimal, int slippageBps))
|
||||
// Reads the token list config at TOKENS_CONFIG (absolute path, JSON array
|
||||
// of { symbol, name, definitionId, holding, decimals }) and returns it as
|
||||
// a QVariantList of QVariantMap entries. Returns an empty list if
|
||||
|
||||
Reference in New Issue
Block a user