From 4363f13912e0ba8627e70575161862ab337c1169 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:10:55 +0200 Subject: [PATCH] feat(apps/amm): submit exact-output swaps and drop client-side swap math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the Buy direction to submit: AmmUiBackend gains a swapExactOutput slot (guarded like swapExactInput), SwapCard.executeSwap branches on direction — sell -> swapExactInput(minReceivedRaw), buy -> swapExactOutput(maxInRaw) — and canSubmit/submitButtonText/buildSnapshot handle both. The confirmation dialog switches wording by mode ("You pay at most" / "You receive exactly" for exact output). The Buy field is now digitsOnly since its value is submitted as a raw base-units integer. With both directions priced and oriented by the module, the client no longer needs any pool math. Remove the reserve-orientation chain (sellIsPoolA, buy/sellReserveNum, poolReserveA/B, poolDefAHex) — resolvePool now reports only existence and fee — and the now-dead helpers (formatBaseUnits/formatAmountValue, DummySwapState.amountInFor/minReceived/priceImpactPercent/maxSent). The impossible-swap guard moves from a client reserve compare to the module's output_exceeds_liquidity error, surfaced as "Insufficient liquidity". --- apps/amm/qml/components/swap/SwapCard.qml | 140 ++++++++---------- .../swap/SwapConfirmationSummary.qml | 18 ++- apps/amm/qml/state/DummySwapState.qml | 49 ------ apps/amm/src/AmmUiBackend.cpp | 17 +++ apps/amm/src/AmmUiBackend.h | 3 + apps/amm/src/AmmUiBackend.rep | 6 + 6 files changed, 96 insertions(+), 137 deletions(-) diff --git a/apps/amm/qml/components/swap/SwapCard.qml b/apps/amm/qml/components/swap/SwapCard.qml index 0b68147..daf8c25 100644 --- a/apps/amm/qml/components/swap/SwapCard.qml +++ b/apps/amm/qml/components/swap/SwapCard.qml @@ -5,8 +5,9 @@ import "../shared" import "../../state" // The real swap UI: two token inputs (sell/buy), a token picker (backed by -// AmmUiBackend::tokenList()/TOKENS_CONFIG via SwapPage), and a submit flow -// wired straight to the backend's resolvePool()/swapExactInput() slots — see +// AmmUiBackend::tokenList()/TOKENS_CONFIG via SwapPage). Editing either side +// server-quotes that direction (swapExactInQuote / swapExactOutQuote) and the +// matching submit slot (swapExactInput / swapExactOutput) runs on confirm — see // apps/amm/src/AmmUiBackend.rep for the exact contract. Rectangle { id: root @@ -24,17 +25,17 @@ Rectangle { property real slippageTolerancePercent: 0.5 // ── Pool resolution (backend.resolvePool) ─────────────────────────────── - // A pool's PoolDefinition stores def_a/def_b in the pool CREATOR's order - // (from NewDefinition), not sorted, and not necessarily in (sellToken, - // buyToken) order. reserveA/reserveB mirror that same canonical order, so - // they must be mapped to sell/buy via poolDefAHex below — never assumed. + // Existence and fee drive the UI; the swap quotes read the pool and + // price/orient the swap server-side, so the client no longer prices against + // the reserves. The raw reserves are still surfaced (observability only, not + // used for pricing) so the e2e test can assert they changed on-chain after a + // swap. property bool poolLoading: false property bool poolResolved: false property bool poolExists: false + property int poolFeeBps: 30 property string poolReserveA: "0" property string poolReserveB: "0" - property string poolDefAHex: "" - property int poolFeeBps: 30 property string poolError: "" // ── Exact-input quote (backend.swapExactInQuote) ──────────────────────── @@ -130,7 +131,6 @@ Rectangle { root.poolExists = !!(pool && pool.exists) root.poolReserveA = (pool && pool.reserveA) || "0" root.poolReserveB = (pool && pool.reserveB) || "0" - root.poolDefAHex = (pool && pool.defAHex) || "" // feeBps === 0 is a legitimate zero-fee pool; only fall back // to the 30bps default when the backend didn't send a value. root.poolFeeBps = (pool && pool.feeBps !== undefined) ? pool.feeBps : 30 @@ -326,17 +326,6 @@ Rectangle { }) } - // 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 - // the user's raw input text, passed through verbatim. - // The pool's reserveA/reserveB follow the pool's canonical def_a/def_b - // order (see poolDefAHex above), which may or may not match sell/buy — - // map them explicitly rather than assuming reserveA == sell. - readonly property bool sellIsPoolA: !!root.sellToken && root.sellToken.definitionId === root.poolDefAHex - readonly property real sellReserveNum: Number(sellIsPoolA ? root.poolReserveA : root.poolReserveB) || 0 - readonly property real buyReserveNum: Number(sellIsPoolA ? root.poolReserveB : root.poolReserveA) || 0 - readonly property real parsedSellInput: { var amt = parseFloat(sellInput) return isNaN(amt) || amt < 0 ? 0 : amt @@ -376,36 +365,40 @@ Rectangle { ? root.quotePriceImpactBps / 100 : root.quoteOutPriceImpactBps / 100 - readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output (preview only)") : qsTr("Exact input") + readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output") : qsTr("Exact input") readonly property bool hasAmount: editingSide === "sell" ? parsedSellInput > 0 : parsedBuyInput > 0 readonly property bool tokensSelected: sellToken !== null && buyToken !== null - readonly property bool insufficientLiquidity: hasAmount && root.poolExists && parsedBuyAmount > buyReserveNum + // Exact output only: the module reports output_exceeds_liquidity when the + // requested output is at least the pool's reserve. Exact input can never + // exceed the reserve, so it has no such case. + readonly property bool outputExceedsLiquidity: editingSide === "buy" && root.quoteOutError === "output_exceeds_liquidity" + // Loading flag for whichever direction the user is editing. + readonly property bool quoteLoading: editingSide === "sell" ? root.quoteInLoading : root.quoteOutLoading // True only when THIS app's wallet is connected. The backend also enforces - // this before submitting (AmmUiBackend::swapExactInput), but gate the UI too - // so a disconnected app never even initiates a swap against the shared wallet. + // this before submitting, but gate the UI too so a disconnected app never + // even initiates a swap against the shared wallet. readonly property bool walletOpen: root.backend !== null && root.backend.isWalletOpen - // The backend only exposes swapExactInput, so only the "I know exactly - // how much I'm selling" direction can actually be submitted. Editing the - // buy field still previews an estimate (via amountInFor above) but can't - // be submitted — see doc comment on AmmUiBackend::swapExactInput. - readonly property bool canSubmit: tokensSelected && editingSide === "sell" && hasAmount + // Both directions are submittable: exact input via swapExactInput, exact + // output via swapExactOutput. The typed side and the quoted side must both be + // positive (a fresh quote has landed) and the active quote must not be + // in-flight. + readonly property bool canSubmit: tokensSelected && hasAmount && parsedSellAmount > 0 && parsedBuyAmount > 0 && root.poolResolved && root.poolExists - && !insufficientLiquidity && !root.swapInProgress - && !root.quoteInLoading && root.walletOpen + && !outputExceedsLiquidity && !root.swapInProgress + && !root.quoteLoading && root.walletOpen readonly property string submitButtonText: { if (!tokensSelected) return qsTr("Select tokens") if (root.swapInProgress) return qsTr("Submitting…") if (!hasAmount) return qsTr("Enter an amount") - if (editingSide === "buy") return qsTr("Enter a sell amount to swap") if (root.poolLoading || !root.poolResolved) return qsTr("Resolving pool…") if (!root.poolExists) return qsTr("No pool / no liquidity") - if (root.quoteInLoading) return qsTr("Quoting…") - if (insufficientLiquidity) return qsTr("Insufficient liquidity") - if (parsedBuyAmount <= 0) return qsTr("Amount too small") + if (root.quoteLoading) return qsTr("Quoting…") + if (outputExceedsLiquidity) return qsTr("Insufficient liquidity") + if (parsedSellAmount <= 0 || parsedBuyAmount <= 0) return qsTr("Amount too small") if (!root.walletOpen) return qsTr("Connect wallet to swap") return qsTr("Swap") } @@ -416,37 +409,12 @@ Rectangle { if (root.poolLoading) return qsTr("Looking up pool…") if (root.poolError.length > 0) return root.poolError if (root.poolResolved && !root.poolExists) return qsTr("No pool / no liquidity for this pair.") + if (root.outputExceedsLiquidity) return qsTr("Not enough liquidity for that output amount.") 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 "" } - function formatAmountValue(val) { - if (val >= 1) return val.toFixed(2) - if (val >= 0.0001) return val.toFixed(6) - return val.toFixed(8) - } - - // Base units are integers; render the (estimate-only) computed side as a - // plain integer string rather than a fractional/scientific one. - function formatBaseUnits(value) { - if (!isFinite(value) || isNaN(value) || value <= 0) - return "0" - - var rounded = Math.floor(value) - var s = rounded.toString() - if (s.indexOf("e") === -1 && s.indexOf("E") === -1) - return s - - var match = s.match(/^(\d)(?:\.(\d+))?e\+(\d+)$/i) - if (!match) - return s - - var digits = match[1] + (match[2] || "") - var exponent = parseInt(match[3], 10) - 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. @@ -458,28 +426,29 @@ Rectangle { ? buyInput : ((root.quoteExpectedOutRaw && root.quoteExpectedOutRaw !== "0") ? root.quoteExpectedOutRaw : "") - // Only reached in the Sell (exact-input) direction — canSubmit gates the CTA - // to editingSide === "sell" — so the amounts come straight from the raw - // input and the quote's exact-integer figures. + // Confirmation-dialog preview. The typed side is exact; the quoted side and + // the slippage bound come from the quote's exact-integer strings. boundValue + // is the min received (exact input) or the max sent (exact output). function buildSnapshot() { + var isExactIn = editingSide === "sell" return { "sellToken": sellToken ? sellToken.symbol : "", "buyToken": buyToken ? buyToken.symbol : "", - "sellAmount": root.sellInput, - "buyAmount": root.quoteExpectedOutRaw, - "minReceived": root.quoteMinReceivedRaw, + "sellAmount": isExactIn ? root.sellInput : root.quoteRequiredInRaw, + "buyAmount": isExactIn ? root.quoteExpectedOutRaw : root.buyInput, + "boundValue": isExactIn ? root.quoteMinReceivedRaw : root.quoteMaxInRaw, "feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""), "priceImpactPercent": swapState.formatPercent(priceImpactPercent), "priceImpactPercentValue": priceImpactPercent, "slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent), - "swapMode": "swap-exact-input", + "swapMode": isExactIn ? "swap-exact-input" : "swap-exact-output", "swapModeText": swapModeText } } // Called by SwapPage once the user confirms in SwapConfirmationDialog. - // Submits the real on-chain swap for the amounts/tokens selected when the - // CTA was pressed (canSubmit already guarantees editingSide === "sell"). + // Submits the real on-chain swap for the tokens/amounts in SwapCard's live + // state, in whichever direction the user is editing. function executeSwap() { if (!root.backend || !root.canSubmit) return @@ -487,26 +456,30 @@ Rectangle { root.swapInProgress = true root.swapError = "" - // The submitted slippage floor is the quote's exact-integer minReceivedRaw - // (base units), derived server-side from the same formula the chain uses — - // no client-side reserve orientation or double-precision recompute. - var minOutStr = root.quoteMinReceivedRaw // Max u64 sentinel: "ignore deadline", per AmmUiBackend.rep. var deadline = "18446744073709551615" + var inDef = root.sellToken.definitionId + var outDef = root.buyToken.definitionId + var inHolding = root.sellToken.holding + var outHolding = root.buyToken.holding - logos.watch(root.backend.swapExactInput( - root.sellToken.definitionId, root.buyToken.definitionId, - root.sellToken.holding, root.buyToken.holding, - root.sellInput, minOutStr, deadline), + // The on-chain guard is the quote's exact-integer bound: the exact-input + // floor (minReceivedRaw) or the exact-output ceiling (maxInRaw). The typed + // side (sellInput / buyInput) is the exact amount for that direction. + var pending = root.editingSide === "sell" + ? root.backend.swapExactInput(inDef, outDef, inHolding, outHolding, + root.sellInput, root.quoteMinReceivedRaw, deadline) + : root.backend.swapExactOutput(inDef, outDef, inHolding, outHolding, + root.buyInput, root.quoteMaxInRaw, deadline) + + logos.watch(pending, function (txHash) { root.swapInProgress = false if (txHash && txHash.length > 0) { root.swapSucceeded({ "txHash": txHash, "sellToken": root.sellToken.symbol, - "buyToken": root.buyToken.symbol, - "sellAmount": root.sellInput, - "minReceived": minOutStr + "buyToken": root.buyToken.symbol }) root.resetAmounts() resolveDebounce.restart() @@ -516,7 +489,7 @@ Rectangle { } }, function (error) { - console.warn("swapExactInput error:", error) + console.warn("swap error:", error) root.swapInProgress = false root.swapError = qsTr("Swap error: %1").arg(error) root.swapFailed(root.swapError) @@ -609,6 +582,9 @@ Rectangle { amount: root.buyDisplay token: root.buyToken active: root.editingSide === "buy" + // Exact-output amount is sent to the backend as a raw base-units + // integer string; reject fractional entry rather than fail opaquely. + digitsOnly: true onInputEdited: function(v) { root.buyInput = v if (root.editingSide !== "buy") root.editingSide = "buy" diff --git a/apps/amm/qml/components/swap/SwapConfirmationSummary.qml b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml index a77cfd8..cf321ff 100644 --- a/apps/amm/qml/components/swap/SwapConfirmationSummary.qml +++ b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml @@ -7,6 +7,11 @@ ColumnLayout { property var theme property var snapshot: ({}) + // Exact output guarantees the received amount and caps the spent amount; + // exact input is the reverse. The wording and which value is the bound flip + // accordingly. + readonly property bool isExactOut: (root.snapshot.swapMode || "") === "swap-exact-output" + spacing: 10 Rectangle { @@ -23,7 +28,7 @@ ColumnLayout { Text { Layout.fillWidth: true - text: qsTr("You pay") + text: root.isExactOut ? qsTr("You pay at most") : qsTr("You pay") color: root.theme.colors.textSecondary font.pixelSize: 12 } @@ -31,7 +36,7 @@ ColumnLayout { Text { Layout.fillWidth: true text: qsTr("%1 %2") - .arg(root.snapshot.sellAmount || "") + .arg((root.isExactOut ? root.snapshot.boundValue : root.snapshot.sellAmount) || "") .arg(root.snapshot.sellToken || "") color: root.theme.colors.textPrimary font.bold: true @@ -55,7 +60,7 @@ ColumnLayout { Text { Layout.fillWidth: true - text: qsTr("You receive at least") + text: root.isExactOut ? qsTr("You receive exactly") : qsTr("You receive at least") color: root.theme.colors.textSecondary font.pixelSize: 12 } @@ -63,7 +68,7 @@ ColumnLayout { Text { Layout.fillWidth: true text: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") + .arg((root.isExactOut ? root.snapshot.buyAmount : root.snapshot.boundValue) || "") .arg(root.snapshot.buyToken || "") color: root.theme.colors.textPrimary font.bold: true @@ -81,8 +86,9 @@ ColumnLayout { priceImpactText: root.snapshot.priceImpactPercent || "" priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 slippageText: root.snapshot.slippageTolerance || "" + boundLabel: root.isExactOut ? qsTr("Maximum sent") : qsTr("Min received") boundText: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") + .arg(root.snapshot.boundValue || "") + .arg((root.isExactOut ? root.snapshot.sellToken : root.snapshot.buyToken) || "") } } diff --git a/apps/amm/qml/state/DummySwapState.qml b/apps/amm/qml/state/DummySwapState.qml index c20f227..553626a 100644 --- a/apps/amm/qml/state/DummySwapState.qml +++ b/apps/amm/qml/state/DummySwapState.qml @@ -17,55 +17,6 @@ QtObject { return parseAmount(amountIn) * root.feeBps / 10000; } - function amountInFor(amountOut, reserveIn, reserveOut) { - const safeAmountOut = parseAmount(amountOut); - const safeReserveIn = parseAmount(reserveIn); - const safeReserveOut = parseAmount(reserveOut); - - if (safeAmountOut <= 0 || safeReserveIn <= 0 || safeReserveOut <= 0) { - return 0; - } - if (safeAmountOut >= safeReserveOut) { - return 0; - } - - const amountInAfterFee = safeAmountOut * safeReserveIn / (safeReserveOut - safeAmountOut); - return amountInAfterFee * 10000 / (10000 - root.feeBps); - } - - function priceImpactPercent(amountIn, amountOut, reserveIn, reserveOut) { - const safeAmountIn = parseAmount(amountIn); - const safeAmountOut = parseAmount(amountOut); - const safeReserveIn = parseAmount(reserveIn); - const safeReserveOut = parseAmount(reserveOut); - - if (safeAmountIn <= 0 || safeAmountOut <= 0) { - return 0; - } - if (safeReserveIn <= 0 || safeReserveOut <= 0) { - return 0; - } - if (safeReserveOut - safeAmountOut <= 0) { - return 0; - } - - const priceBefore = safeReserveIn / safeReserveOut; - const priceAfter = (safeReserveIn + safeAmountIn) / (safeReserveOut - safeAmountOut); - return (priceAfter - priceBefore) / priceBefore * 100; - } - - function minReceived(amountOut, slippagePercent) { - const safeAmount = parseAmount(amountOut); - const safeSlippage = clampSlippagePercent(slippagePercent); - return safeAmount * (1 - safeSlippage / 100); - } - - function maxSent(amountIn, slippagePercent) { - const safeAmount = parseAmount(amountIn); - const safeSlippage = clampSlippagePercent(slippagePercent); - return safeAmount * (1 + safeSlippage / 100); - } - function formatAmountValue(value) { const amount = Math.max(0, Number(value) || 0); if (amount >= 1) return amount.toFixed(2); diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index e20f9ba..0cabae4 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -250,6 +250,23 @@ QVariantMap AmmUiBackend::swapExactOutQuote(QString tokenInHex, QString tokenOut tokenInHex, tokenOutHex, amountOutDecimal, slippageBps); } +QString AmmUiBackend::swapExactOutput(QString defAHex, QString defBHex, QString userInputHoldingHex, + QString userOutputHoldingHex, QString amountOutDecimal, + QString maxInDecimal, QString deadlineDecimal) +{ + // Same connected-state submit guard as swapExactInput — this app's lock is + // authoritative even though the shared wallet may remain open elsewhere. + if (!isWalletOpen()) + return {}; + + const QString txHash = m_logos->amm_module.swapExactOutput( + defAHex, defBHex, userInputHoldingHex, userOutputHoldingHex, + amountOutDecimal, maxInDecimal, deadlineDecimal); + if (!txHash.isEmpty()) + refreshBalances(); + return txHash; +} + QVariantList AmmUiBackend::tokenList() { return m_logos->amm_module.tokenList(); diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index bc8cc0f..3ddef29 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -65,6 +65,9 @@ public slots: QString amountInDecimal, int slippageBps) override; QVariantMap swapExactOutQuote(QString tokenInHex, QString tokenOutHex, QString amountOutDecimal, int slippageBps) override; + QString swapExactOutput(QString defAHex, QString defBHex, QString userInputHoldingHex, + QString userOutputHoldingHex, QString amountOutDecimal, + QString maxInDecimal, QString deadlineDecimal) 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; diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index bb01187..7cdbf1c 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -81,6 +81,12 @@ class AmmUiBackend // output_exceeds_liquidity, config_missing, bad_amount, backend_error. // Read-only, no submission. SLOT(QVariantMap swapExactOutQuote(QString tokenInHex, QString tokenOutHex, QString amountOutDecimal, int slippageBps)) + // Submits a real on-chain SwapExactOutput transaction against the pool for + // (defAHex, defBHex). amountOutDecimal is the exact desired output (base + // units); maxInDecimal is the slippage ceiling on the input actually spent; + // deadlineDecimal is a decimal-string u64 unix timestamp in MILLISECONDS. + // Same return contract as swapExactInput (tx hash, empty string on failure). + SLOT(QString swapExactOutput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountOutDecimal, QString maxInDecimal, QString deadlineDecimal)) // 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