feat(apps/amm): drive the exact-input swap preview from swapExactInQuote

Wire the Sell direction of the swap card to the module's server-side quote
instead of the client-side DummySwapState estimate. AmmUiBackend gains a
swapExactInQuote(tokenIn, tokenOut, amountInDecimal, slippageBps) slot that
forwards to amm_module and returns { expectedOutRaw, minReceivedRaw,
priceImpactBps } (read-only, no wallet guard).

SwapCard debounces a swapExactInQuote call as the user types a Sell amount and
sources the expected output, min received, and price impact from it. The exact
figures shown (buy field, confirmation snapshot) and the submitted min_out come
straight from the quote's raw integer strings, so the preview can't drift from
execution and the client no longer orients reserves (fixes the #236 defAHex
bug) or recomputes min_out in double/BigInt. A retyped amount invalidates the
quote immediately and blocks submit until the re-quote lands.

The Buy direction is unchanged — still a local DummySwapState preview, pending
the exact-output wiring. resolvePool stays as the source of pool existence, the
fee row, and the Buy-side reserves.
This commit is contained in:
r4bbit
2026-08-06 23:15:01 +02:00
parent 56b2d3a282
commit 37c2f294ac
7 changed files with 164 additions and 80 deletions
+124 -19
View File
@@ -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"
-51
View File
@@ -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);
+10
View File
@@ -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();
+2
View File
@@ -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;
+8
View File
@@ -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:<code> } — 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
+15 -7
View File
@@ -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
+5 -3
View File
@@ -47,8 +47,9 @@ public:
/// decimal string (JSON floats rejected); `slippage_bps` is basis points.
/// On failure: `{ status:"error", error:<code> }` — `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:<code> }` — `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,