diff --git a/apps/amm/qml/components/swap/SwapCard.qml b/apps/amm/qml/components/swap/SwapCard.qml index 9d45313..c2a485b 100644 --- a/apps/amm/qml/components/swap/SwapCard.qml +++ b/apps/amm/qml/components/swap/SwapCard.qml @@ -37,6 +37,13 @@ Rectangle { property int poolFeeBps: 30 property string poolError: "" + // ── Exact-input quote (backend.swapExactInQuote) ──────────────────────── + property bool quoteInLoading: false + property string quoteInError: "" + property string quoteExpectedOutRaw: "0" + property string quoteMinReceivedRaw: "0" + property int quotePriceImpactBps: 0 + // ── Swap submission (backend.swapExactInput) ──────────────────────────── property bool swapInProgress: false property string swapError: "" @@ -81,8 +88,11 @@ Rectangle { resolveDebounce.stop() } - onSellTokenChanged: root.requestResolve() - onBuyTokenChanged: root.requestResolve() + onSellTokenChanged: { root.requestResolve(); root.requestQuoteIn() } + onBuyTokenChanged: { root.requestResolve(); root.requestQuoteIn() } + onSellInputChanged: root.requestQuoteIn() + onEditingSideChanged: root.requestQuoteIn() + onSlippageTolerancePercentChanged: root.requestQuoteIn() function doResolvePool() { if (!root.backend || !root.sellToken || !root.buyToken) @@ -129,6 +139,91 @@ Rectangle { }) } + // ── Exact-input quote ────────────────────────────────────────────────────── + Timer { + id: quoteInDebounce + interval: 350 + repeat: false + onTriggered: root.doQuoteIn() + } + + function resetQuoteIn() { + root.quoteExpectedOutRaw = "0" + root.quoteMinReceivedRaw = "0" + root.quotePriceImpactBps = 0 + } + + function requestQuoteIn() { + root.quoteInError = "" + // Only the exact-input (Sell) direction is server-quoted; the Buy + // direction is still a local preview (see DummySwapState). + if (root.backend && root.editingSide === "sell" && root.sellToken && root.buyToken + && root.parsedSellInput > 0) { + // Invalidate the previous quote up front: while a re-quote is pending + // (debounce + in-flight), the stale expected-out / min_out must not be + // shown or submittable. quoteInLoading gates canSubmit until the fresh + // quote lands. Gating on backend here avoids setting the loading flag + // when doQuoteIn would only bail — which would leave it stuck. + root.resetQuoteIn() + root.quoteInLoading = true + quoteInDebounce.restart() + } else { + quoteInDebounce.stop() + root.quoteInLoading = false + root.resetQuoteIn() + } + } + + function doQuoteIn() { + if (!root.backend || root.editingSide !== "sell" + || !root.sellToken || !root.buyToken || root.parsedSellInput <= 0) { + root.quoteInLoading = false + return + } + + // Capture the request identity: quote callbacks can arrive out of order, + // so a stale one (tokens or the typed amount changed since) must not + // overwrite the current preview or the submitted min_out. + var reqSell = root.sellToken.definitionId + var reqBuy = root.buyToken.definitionId + var reqAmount = root.sellInput + function isStale() { + return root.editingSide !== "sell" + || !root.sellToken || !root.buyToken + || root.sellToken.definitionId !== reqSell + || root.buyToken.definitionId !== reqBuy + || root.sellInput !== reqAmount + } + + var slippageBps = Math.round(root.slippageTolerancePercent * 100) + root.quoteInLoading = true + logos.watch(root.backend.swapExactInQuote(reqSell, reqBuy, reqAmount, slippageBps), + function (quote) { + if (isStale()) + return + root.quoteInLoading = false + if (quote && quote.status === "ok") { + root.quoteExpectedOutRaw = quote.expectedOutRaw || "0" + root.quoteMinReceivedRaw = quote.minReceivedRaw || "0" + root.quotePriceImpactBps = quote.priceImpactBps || 0 + root.quoteInError = "" + } else { + root.resetQuoteIn() + // no_pool is surfaced via the pool status text, not as an error. + var code = (quote && quote.error) || "backend_error" + root.quoteInError = code === "no_pool" ? "" : code + } + }, + function (error) { + if (isStale()) + return + console.warn("swapExactInQuote error:", error) + root.quoteInLoading = false + root.resetQuoteIn() + root.quoteInError = 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 @@ -154,13 +249,21 @@ Rectangle { ? parsedSellInput : swapState.amountInFor(parsedBuyInput, sellReserveNum, buyReserveNum) + // 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 - : swapState.amountOutFor(parsedSellInput, sellReserveNum, buyReserveNum) + : (Number(root.quoteExpectedOutRaw) || 0) readonly property real feeAmount: swapState.feeAmount(parsedSellAmount) - readonly property real minReceivedAmount: swapState.minReceived(parsedBuyAmount, slippageTolerancePercent) - readonly property real priceImpactPercent: swapState.priceImpactPercent(parsedSellAmount, parsedBuyAmount, sellReserveNum, buyReserveNum) + readonly property real minReceivedAmount: editingSide === "sell" + ? (Number(root.quoteMinReceivedRaw) || 0) + : swapState.minReceived(parsedBuyAmount, slippageTolerancePercent) + readonly property real priceImpactPercent: editingSide === "sell" + ? root.quotePriceImpactBps / 100 + : swapState.priceImpactPercent(parsedSellAmount, parsedBuyAmount, sellReserveNum, buyReserveNum) readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output (preview only)") : qsTr("Exact input") @@ -180,7 +283,7 @@ Rectangle { && parsedSellAmount > 0 && parsedBuyAmount > 0 && root.poolResolved && root.poolExists && !insufficientLiquidity && !root.swapInProgress - && root.walletOpen + && !root.quoteInLoading && root.walletOpen readonly property string submitButtonText: { if (!tokensSelected) return qsTr("Select tokens") @@ -189,6 +292,7 @@ Rectangle { 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.walletOpen) return qsTr("Connect wallet to swap") @@ -201,6 +305,7 @@ 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.quoteInError.length > 0) return qsTr("Quote failed: %1").arg(root.quoteInError) return "" } @@ -234,17 +339,22 @@ Rectangle { ? sellInput : (parsedSellAmount > 0 ? formatBaseUnits(parsedSellAmount) : "") + // 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 - : (parsedBuyAmount > 0 ? formatBaseUnits(parsedBuyAmount) : "") + : ((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. function buildSnapshot() { return { "sellToken": sellToken ? sellToken.symbol : "", "buyToken": buyToken ? buyToken.symbol : "", - "sellAmount": formatBaseUnits(parsedSellAmount), - "buyAmount": formatBaseUnits(parsedBuyAmount), - "minReceived": formatBaseUnits(minReceivedAmount), + "sellAmount": root.sellInput, + "buyAmount": root.quoteExpectedOutRaw, + "minReceived": root.quoteMinReceivedRaw, "feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""), "priceImpactPercent": swapState.formatPercent(priceImpactPercent), "priceImpactPercentValue": priceImpactPercent, @@ -264,15 +374,10 @@ Rectangle { root.swapInProgress = true root.swapError = "" - // Compute the submitted slippage floor with exact integer (BigInt) math - // rather than the double-based preview: base-unit values for 18-decimal - // tokens exceed 2^53, where doubles would understate min_out and weaken - // price protection. Sell/buy reserves follow the pool's canonical order. - var minOutStr = swapState.minOutBaseUnits( - root.sellInput, - root.sellIsPoolA ? root.poolReserveA : root.poolReserveB, - root.sellIsPoolA ? root.poolReserveB : root.poolReserveA, - root.slippageTolerancePercent) + // 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" diff --git a/apps/amm/qml/state/DummySwapState.qml b/apps/amm/qml/state/DummySwapState.qml index e43f6f8..c20f227 100644 --- a/apps/amm/qml/state/DummySwapState.qml +++ b/apps/amm/qml/state/DummySwapState.qml @@ -17,19 +17,6 @@ QtObject { return parseAmount(amountIn) * root.feeBps / 10000; } - function amountOutFor(amountIn, reserveIn, reserveOut) { - const safeAmountIn = parseAmount(amountIn); - const safeReserveIn = parseAmount(reserveIn); - const safeReserveOut = parseAmount(reserveOut); - - if (safeAmountIn <= 0 || safeReserveIn <= 0 || safeReserveOut <= 0) { - return 0; - } - - const amountInAfterFee = safeAmountIn * (10000 - root.feeBps) / 10000; - return safeReserveOut * amountInAfterFee / (safeReserveIn + amountInAfterFee); - } - function amountInFor(amountOut, reserveIn, reserveOut) { const safeAmountOut = parseAmount(amountOut); const safeReserveIn = parseAmount(reserveIn); @@ -73,44 +60,6 @@ QtObject { return safeAmount * (1 - safeSlippage / 100); } - // Exact-integer minimum-received (base units) for a SwapExactInput, used as - // the on-chain slippage floor that is actually submitted. Computed in BigInt - // (arbitrary precision, mirroring the on-chain u256 math): base units for - // 18-decimal tokens exceed 2^53 and even overflow u128 intermediates, so JS - // doubles silently lose precision and would understate min_out — weakening - // the user's price protection. amountIn/reserveIn/reserveOut are base-unit - // integer strings; returns a decimal string. Falls back to the double - // estimate only if BigInt is unavailable in this Qt build. - function minOutBaseUnits(amountIn, reserveIn, reserveOut, slippagePercent) { - if (typeof BigInt !== "undefined") { - var toBig = function (x) { - var s = String(x).trim(); - return /^[0-9]+$/.test(s) ? BigInt(s) : BigInt(0); - }; - var zero = BigInt(0); - var denom = BigInt(10000); - var amtIn = toBig(amountIn); - var resIn = toBig(reserveIn); - var resOut = toBig(reserveOut); - if (amtIn <= zero || resIn <= zero || resOut <= zero) - return "0"; - - var feeBps = BigInt(Math.round(Math.min(10000, Math.max(0, Number(root.feeBps) || 0)))); - var amtInAfterFee = amtIn * (denom - feeBps) / denom; // floor - if (amtInAfterFee <= zero) - return "0"; - var out = resOut * amtInAfterFee / (resIn + amtInAfterFee); // floor - var slipBps = BigInt(Math.round(clampSlippagePercent(slippagePercent) * 100)); - if (slipBps < zero) slipBps = zero; - if (slipBps > denom) slipBps = denom; - var minOut = out * (denom - slipBps) / denom; // floor - return minOut.toString(); - } - // Legacy double fallback (no worse than before if BigInt is missing). - var estOut = amountOutFor(amountIn, reserveIn, reserveOut); - return String(Math.floor(Math.max(0, minReceived(estOut, slippagePercent)))); - } - function maxSent(amountIn, slippagePercent) { const safeAmount = parseAmount(amountIn); const safeSlippage = clampSlippagePercent(slippagePercent); diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 967f163..081af9b 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -231,6 +231,16 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u return txHash; } +QVariantMap AmmUiBackend::swapExactInQuote(QString tokenInHex, QString tokenOutHex, + QString amountInDecimal, int slippageBps) +{ + // Read-only preview — no wallet guard. The module reads the pool and prices + // the swap server-side; the returned envelope orients reserves and computes + // expectedOut/minReceived/priceImpact via the same formula the chain uses. + return m_logos->amm_module.swapExactInQuote( + tokenInHex, tokenOutHex, amountInDecimal, slippageBps); +} + QVariantList AmmUiBackend::tokenList() { return m_logos->amm_module.tokenList(); diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 3cda4eb..955c585 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -61,6 +61,8 @@ public slots: QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal) override; + QVariantMap swapExactInQuote(QString tokenInHex, QString tokenOutHex, + QString amountInDecimal, 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; diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 447ca28..ec5da60 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -64,6 +64,14 @@ class AmmUiBackend // is enforced). Returns the tx hash, or an empty string on failure // (no pool, unreadable AMM_PROGRAM_BIN, bad inputs, or a failed tx). SLOT(QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal)) + // Server-side SwapExactInput preview for (tokenInHex, tokenOutHex): reads the + // pool and returns { status:"ok", error:"", expectedOutRaw, minReceivedRaw, + // priceImpactBps }, oriented and priced via the shared on-chain formula. + // amountInDecimal is a decimal-string base-unit amount; slippageBps is basis + // points. On failure { status:"error", error: } — no_pool, + // 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)) // 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 diff --git a/apps/amm/tests/swap.mjs b/apps/amm/tests/swap.mjs index 30c1bd2..05fef92 100644 --- a/apps/amm/tests/swap.mjs +++ b/apps/amm/tests/swap.mjs @@ -1,5 +1,5 @@ // --------------------------------------------------------------------------- -// AMM UI test — swap 10000 of the first token in the list for the second. +// AMM UI test — swap the first token in the list for the second (SELL_AMOUNT). // // Drives the running AMM UI through the QML inspector (logos-qt-mcp). Run it // against a LIVE app window so you can watch it happen — see "Running the UI @@ -24,7 +24,7 @@ const fwRoot = new URL("../result-mcp", import.meta.url).pathname; const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs")); -const SELL_AMOUNT = "10000"; +const SELL_AMOUNT = "100"; // --- small helpers over the raw inspector commands ------------------------- @@ -86,13 +86,21 @@ async function pickToken(app, index) { // Enter the sell amount by setting the SwapCard's state directly. Synthesizing // keystrokes needs the TextInput to hold active focus, which the inspector -// can't reliably grant headlessly; setting sellInput drives the exact same -// reactive flow (estimate -> CTA -> confirm -> submit) and the bound TextInput -// still displays the value. +// can't reliably grant headlessly; setting sellInput updates the property (and +// the bound TextInput display) so the reactive flow can run. +// +// Setting a property programmatically re-evaluates dependent BINDINGS but does +// not fire the onSellInputChanged HANDLER the way real typing does — and the +// Sell preview is now driven by that handler (onSellInputChanged -> +// requestQuoteIn -> async backend.swapExactInQuote), not a synchronous binding. +// So kick the quote explicitly, mirroring the doResolvePool() nudge used in the +// reserve-change check below. Without this the CTA never leaves "Amount too +// small" because the server quote never fires. async function setSellAmount(app, amount) { const cardId = await idByObjectName(app, "swapCard"); await app.inspector.send("setProperty", { objectId: cardId, property: "editingSide", value: "sell" }); await app.inspector.send("setProperty", { objectId: cardId, property: "sellInput", value: String(amount) }); + await app.inspector.send("evaluate", { expression: "requestQuoteIn()", objectId: cardId }); } // Read the SwapCard's swap/pool state — explains WHY the CTA isn't "Swap" yet. @@ -136,7 +144,7 @@ async function saveShot(app, name) { // --- the test --------------------------------------------------------------- -test("amm swap: sell 10000 of token #1 for token #2", async (app) => { +test("amm swap: sell token #1 for token #2", async (app) => { // 1. Wait for the swap card to render (Trade tab is the default, index 0). await app.waitFor( async () => { await app.expectTexts(["Sell", "Buy"]); }, @@ -153,7 +161,7 @@ test("amm swap: sell 10000 of token #1 for token #2", async (app) => { const second = await pickToken(app, 1); console.log(` sell ${first} -> buy ${second}`); - // 5. Enter 10000 as the sell amount. + // 5. Enter the sell amount. await setSellAmount(app, SELL_AMOUNT); await app.expectTexts([SELL_AMOUNT]); // the amount should now be visible diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index 5432be2..dd59feb 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -47,8 +47,9 @@ public: /// decimal string (JSON floats rejected); `slippage_bps` is basis points. /// On failure: `{ status:"error", error: }` — `no_pool` (no pool / /// liquidity), `config_missing` (AMM_PROGRAM_BIN unset/unreadable), - /// `bad_amount`, or `backend_error`. Pool metadata (reserves, fee) comes from - /// `resolvePool`, so it isn't echoed here. + /// `bad_amount`, `invalid_slippage` (`slippage_bps` out of range), or + /// `backend_error`. Pool metadata (reserves, fee) comes from `resolvePool`, + /// so it isn't echoed here. LogosMap swapExactInQuote(const std::string& token_in_hex, const std::string& token_out_hex, const nlohmann::json& amount_in, @@ -61,7 +62,8 @@ public: /// string (JSON floats rejected); `slippage_bps` is basis points. On failure: /// `{ status:"error", error: }` — `no_pool` (no pool / liquidity), /// `output_exceeds_liquidity` (amount_out ≥ reserve), `config_missing` - /// (AMM_PROGRAM_BIN unset/unreadable), `bad_amount`, or `backend_error`. + /// (AMM_PROGRAM_BIN unset/unreadable), `bad_amount`, `invalid_slippage` + /// (`slippage_bps` out of range), or `backend_error`. LogosMap swapExactOutQuote(const std::string& token_in_hex, const std::string& token_out_hex, const nlohmann::json& amount_out,