476 lines
19 KiB
QML

import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
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
// apps/amm/src/AmmUiBackend.rep for the exact contract.
Rectangle {
id: root
property var theme
property var tokens: []
// Real backend replica (logos.module("amm_ui")), wired from SwapPage.
property var backend: null
property var sellToken: null
property var buyToken: null
property string sellInput: ""
property string buyInput: ""
property string editingSide: "sell"
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.
property bool poolLoading: false
property bool poolResolved: false
property bool poolExists: false
property string poolReserveA: "0"
property string poolReserveB: "0"
property string poolDefAHex: ""
property int poolFeeBps: 30
property string poolError: ""
// ── Swap submission (backend.swapExactInput) ────────────────────────────
property bool swapInProgress: false
property string swapError: ""
signal requestTokenSelect(string side)
signal submitRequested(var snapshot)
// Emitted after a real swapExactInput call completes.
signal swapSucceeded(var result)
signal swapFailed(string message)
DummySwapState {
id: swapState
feeBps: root.poolFeeBps
}
function setToken(side, token) {
if (side === "sell") root.sellToken = token
else root.buyToken = token
}
function resetAmounts() {
root.sellInput = ""
root.buyInput = ""
root.editingSide = "sell"
}
// ── Pool resolution ──────────────────────────────────────────────────────
Timer {
id: resolveDebounce
interval: 500
repeat: false
onTriggered: root.doResolvePool()
}
function requestResolve() {
root.poolResolved = false
root.poolExists = false
root.poolError = ""
if (root.sellToken && root.buyToken)
resolveDebounce.restart()
else
resolveDebounce.stop()
}
onSellTokenChanged: root.requestResolve()
onBuyTokenChanged: root.requestResolve()
function doResolvePool() {
if (!root.backend || !root.sellToken || !root.buyToken)
return
// Capture the pair this request is for. resolvePool callbacks can arrive
// out of order: if the user switches tokens while an earlier resolve is
// still in flight, its (stale) callback must NOT overwrite the current
// pair's pool state — that would corrupt the preview and the submitted
// min_out. A stale callback also leaves poolLoading alone, since the
// newer in-flight request owns it.
var reqSell = root.sellToken.definitionId
var reqBuy = root.buyToken.definitionId
function isStale() {
return !root.sellToken || !root.buyToken
|| root.sellToken.definitionId !== reqSell
|| root.buyToken.definitionId !== reqBuy
}
root.poolLoading = true
logos.watch(root.backend.resolvePool(reqSell, reqBuy),
function (pool) {
if (isStale())
return
root.poolLoading = false
root.poolResolved = true
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
root.poolError = (pool && pool.error && pool.error !== "no_pool") ? pool.error : ""
},
function (error) {
if (isStale())
return
console.warn("resolvePool error:", error)
root.poolLoading = false
root.poolResolved = true
root.poolExists = false
root.poolError = qsTr("Failed to resolve pool: %1").arg(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
// 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
}
readonly property real parsedBuyInput: {
var amt = parseFloat(buyInput)
return isNaN(amt) || amt < 0 ? 0 : amt
}
readonly property real parsedSellAmount: editingSide === "sell"
? parsedSellInput
: swapState.amountInFor(parsedBuyInput, sellReserveNum, buyReserveNum)
readonly property real parsedBuyAmount: editingSide === "buy"
? parsedBuyInput
: swapState.amountOutFor(parsedSellInput, sellReserveNum, buyReserveNum)
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 string swapModeText: editingSide === "buy" ? qsTr("Exact output (preview only)") : 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
// 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
&& parsedSellAmount > 0 && parsedBuyAmount > 0
&& root.poolResolved && root.poolExists
&& !insufficientLiquidity && !root.swapInProgress
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 (insufficientLiquidity) return qsTr("Insufficient liquidity")
if (parsedBuyAmount <= 0) return qsTr("Amount too small")
return qsTr("Swap")
}
readonly property string poolStatusText: {
if (!root.backend) return qsTr("Wallet backend not ready.")
if (!tokensSelected) return ""
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.")
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)))
}
readonly property string sellDisplay: editingSide === "sell"
? sellInput
: (parsedSellAmount > 0 ? formatBaseUnits(parsedSellAmount) : "")
readonly property string buyDisplay: editingSide === "buy"
? buyInput
: (parsedBuyAmount > 0 ? formatBaseUnits(parsedBuyAmount) : "")
function buildSnapshot() {
return {
"sellToken": sellToken ? sellToken.symbol : "",
"buyToken": buyToken ? buyToken.symbol : "",
"sellAmount": formatBaseUnits(parsedSellAmount),
"buyAmount": formatBaseUnits(parsedBuyAmount),
"minReceived": formatBaseUnits(minReceivedAmount),
"feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""),
"priceImpactPercent": swapState.formatPercent(priceImpactPercent),
"priceImpactPercentValue": priceImpactPercent,
"slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent),
"swapMode": "swap-exact-input",
"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").
function executeSwap() {
if (!root.backend || !root.canSubmit)
return
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)
// Max u64 sentinel: "ignore deadline", per AmmUiBackend.rep.
var deadline = "18446744073709551615"
logos.watch(root.backend.swapExactInput(
root.sellToken.definitionId, root.buyToken.definitionId,
root.sellToken.holding, root.buyToken.holding,
root.sellInput, minOutStr, deadline),
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
})
root.resetAmounts()
resolveDebounce.restart()
} else {
root.swapError = qsTr("Swap failed (empty response from sequencer).")
root.swapFailed(root.swapError)
}
},
function (error) {
console.warn("swapExactInput error:", error)
root.swapInProgress = false
root.swapError = qsTr("Swap error: %1").arg(error)
root.swapFailed(root.swapError)
})
}
radius: 24
color: theme.colors.cardBg
border.color: theme.colors.border
border.width: 1
implicitWidth: 480
implicitHeight: cardLayout.implicitHeight + 16
Behavior on color { ColorAnimation { duration: 300 } }
ColumnLayout {
id: cardLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
spacing: 0
TokenInput {
Layout.fillWidth: true
theme: root.theme
label: "Sell"
amount: root.sellDisplay
token: root.sellToken
active: root.editingSide === "sell"
// Sell 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.sellInput = v
if (root.editingSide !== "sell") root.editingSide = "sell"
}
onTokenClicked: root.requestTokenSelect("sell")
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: 40
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: theme.colors.divider
}
Rectangle {
anchors.centerIn: parent
width: 36; height: 36; radius: 18
color: swapHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg
border.color: theme.colors.borderStrong
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: "↓"
color: theme.colors.textPrimary
font.pixelSize: 16
}
MouseArea {
id: swapHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var tmp = root.sellToken
root.sellToken = root.buyToken
root.buyToken = tmp
}
}
}
}
TokenInput {
Layout.fillWidth: true
theme: root.theme
label: "Buy"
amount: root.buyDisplay
token: root.buyToken
active: root.editingSide === "buy"
onInputEdited: function(v) {
root.buyInput = v
if (root.editingSide !== "buy") root.editingSide = "buy"
}
onTokenClicked: root.requestTokenSelect("buy")
}
Text {
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: root.poolStatusText.length > 0
text: root.poolStatusText
color: (root.poolError.length > 0 || (root.poolResolved && !root.poolExists))
? "#F08A76" : theme.colors.textSecondary
font.pixelSize: 12
wrapMode: Text.WordWrap
}
Text {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: root.swapError.length > 0
text: root.swapError
color: "#F08A76"
font.pixelSize: 12
wrapMode: Text.WordWrap
}
SwapSummary {
Layout.fillWidth: true
Layout.topMargin: 12
Layout.leftMargin: 16
Layout.rightMargin: 16
theme: root.theme
visible: root.tokensSelected && root.hasAmount
swapModeText: root.swapModeText
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 : "")
}
SlippageToleranceControl {
Layout.fillWidth: true
Layout.topMargin: 12
Layout.leftMargin: 16
Layout.rightMargin: 16
tolerancePercent: root.slippageTolerancePercent
visible: root.tokensSelected && root.hasAmount
onToleranceChangeRequested: function(tolerancePercent) {
root.slippageTolerancePercent = swapState.clampSlippagePercent(tolerancePercent);
}
}
Rectangle {
id: ctaBox
Layout.fillWidth: true
Layout.topMargin: 8
Layout.bottomMargin: 8
Layout.leftMargin: 8
Layout.rightMargin: 8
Layout.preferredHeight: 56
radius: 20
color: !root.canSubmit ? theme.colors.panelBg
: ctaHover.containsMouse ? theme.colors.ctaHoverBg
: theme.colors.ctaBg
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: root.submitButtonText
color: root.canSubmit ? "#ffffff" : theme.colors.textSecondary
font.pixelSize: 17
font.weight: Font.Medium
}
MouseArea {
id: ctaHover
anchors.fill: parent
hoverEnabled: true
enabled: root.canSubmit
cursorShape: root.canSubmit ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (root.canSubmit) root.submitRequested(root.buildSnapshot())
}
}
}
}
}