mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-25 08:13:12 +00:00
476 lines
19 KiB
QML
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())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|