feat(apps/amm): submit exact-output swaps and drop client-side swap math

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".
This commit is contained in:
r4bbit
2026-08-06 23:15:02 +02:00
parent d9876d08ca
commit 4363f13912
6 changed files with 96 additions and 137 deletions
+58 -82
View File
@@ -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"
@@ -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) || "")
}
}
-49
View File
@@ -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);
+17
View File
@@ -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();
+3
View File
@@ -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;
+6
View File
@@ -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