diff --git a/apps/amm/qml/Main.qml b/apps/amm/qml/Main.qml index 792f011..6e55e3e 100644 --- a/apps/amm/qml/Main.qml +++ b/apps/amm/qml/Main.qml @@ -83,6 +83,7 @@ Item { SwapPage { anchors.fill: parent visible: navbar.currentIndex === 0 + backend: root.ready ? root.backend : null } LiquidityPage { diff --git a/apps/amm/qml/components/swap/ManualField.qml b/apps/amm/qml/components/swap/ManualField.qml new file mode 100644 index 0000000..97cc207 --- /dev/null +++ b/apps/amm/qml/components/swap/ManualField.qml @@ -0,0 +1,72 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +// A single labeled text field, styled to match the rest of the swap UI's +// theme. Used by ManualSwapPanel for the manual token id / holding address / +// amount inputs. +ColumnLayout { + id: root + + property var theme + property string label: "" + property string placeholder: "" + property string text: "" + // When true, non-digit characters are stripped as the user types (used + // for the base-units amount field). + property bool numericOnly: false + + signal edited(string newValue) + + spacing: 4 + + Text { + text: root.label + color: theme.colors.textSecondary + font.pixelSize: 12 + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 40 + radius: 10 + color: theme.colors.inputBg + border.color: field.activeFocus ? theme.colors.ctaBg : theme.colors.border + border.width: 1 + Behavior on border.color { ColorAnimation { duration: 120 } } + + TextField { + id: field + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + verticalAlignment: TextInput.AlignVCenter + color: theme.colors.textPrimary + placeholderTextColor: theme.colors.textPlaceholder + placeholderText: root.placeholder + font.pixelSize: 13 + selectByMouse: true + background: Item {} + + Binding { + target: field + property: "text" + value: root.text + when: !field.activeFocus + } + + onTextEdited: { + var v = text; + if (root.numericOnly) { + var stripped = v.replace(/[^0-9]/g, ""); + if (stripped !== v) { + field.text = stripped; + return; // onTextEdited fires again for the corrected text + } + v = stripped; + } + root.edited(v); + } + } + } +} diff --git a/apps/amm/qml/components/swap/ManualSwapPanel.qml b/apps/amm/qml/components/swap/ManualSwapPanel.qml new file mode 100644 index 0000000..2688839 --- /dev/null +++ b/apps/amm/qml/components/swap/ManualSwapPanel.qml @@ -0,0 +1,415 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import "../shared" +import "../../state" + +// Minimal, functional "manual" swap flow: the app can't yet enumerate token +// holdings with their definition ids, so the user pastes the real ids/holding +// addresses directly instead of picking from the (dummy) token list. Wires +// straight to the real backend slots (resolvePool / swapExactInput) — see +// apps/amm/src/AmmUiBackend.rep for the exact contract. +Rectangle { + id: root + + property var theme + property var backend: null + + property bool expanded: true + + property string defAHex: "" + property string defBHex: "" + property string holdingAHex: "" + property string holdingBHex: "" + property string amountInText: "" + // "aToB": selling A for B. "bToA": selling B for A. + property string direction: "aToB" + property real slippageTolerancePercent: 0.5 + + property bool poolLoading: false + property bool poolResolved: false + property bool poolExists: false + property string poolReserveA: "0" + property string poolReserveB: "0" + property int poolFeeBps: 30 + property string poolError: "" + + property bool swapInProgress: false + property string swapError: "" + + signal swapSucceeded(var snapshot) + signal swapFailed(string message) + + DummySwapState { + id: swapState + feeBps: root.poolFeeBps + } + + Timer { + id: resolveDebounce + interval: 500 + repeat: false + onTriggered: root.doResolvePool() + } + + function requestResolve() { + root.poolResolved = false; + root.poolExists = false; + root.poolError = ""; + if (root.defAHex.length > 0 && root.defBHex.length > 0) + resolveDebounce.restart(); + else + resolveDebounce.stop(); + } + + onDefAHexChanged: root.requestResolve() + onDefBHexChanged: root.requestResolve() + + function doResolvePool() { + if (!root.backend) { + root.poolError = qsTr("Wallet backend not ready."); + return; + } + if (root.defAHex.length === 0 || root.defBHex.length === 0) + return; + + root.poolLoading = true; + logos.watch(root.backend.resolvePool(root.defAHex, root.defBHex), + function (pool) { + root.poolLoading = false; + root.poolResolved = true; + root.poolExists = !!(pool && pool.exists); + root.poolReserveA = (pool && pool.reserveA) || "0"; + root.poolReserveB = (pool && pool.reserveB) || "0"; + root.poolFeeBps = (pool && pool.feeBps) || 30; + root.poolError = ""; + }, + function (error) { + 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; this is only used to + // format an *estimate* (expected output / min received) as a plain + // decimal string for the backend call, never for the input amount itself + // (which is passed through from user text verbatim). + function formatDecimalInteger(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 real reserveInNum: Number(root.direction === "aToB" ? root.poolReserveA : root.poolReserveB) || 0 + readonly property real reserveOutNum: Number(root.direction === "aToB" ? root.poolReserveB : root.poolReserveA) || 0 + readonly property real amountInNum: { + var v = Number(root.amountInText); + return isNaN(v) || v < 0 ? 0 : v; + } + + readonly property real expectedOutNum: swapState.amountOutFor(root.amountInNum, root.reserveInNum, root.reserveOutNum) + readonly property real minReceivedNum: swapState.minReceived(root.expectedOutNum, root.slippageTolerancePercent) + readonly property real feeAmountNum: swapState.feeAmount(root.amountInNum) + readonly property real priceImpactPercentValue: swapState.priceImpactPercent(root.amountInNum, root.expectedOutNum, root.reserveInNum, root.reserveOutNum) + + readonly property string tokenDefInHex: root.direction === "aToB" ? root.defAHex : root.defBHex + readonly property string tokenDefOutHex: root.direction === "aToB" ? root.defBHex : root.defAHex + + readonly property bool fieldsFilled: root.defAHex.length > 0 && root.defBHex.length > 0 + && root.holdingAHex.length > 0 && root.holdingBHex.length > 0 + && root.amountInNum > 0 + + readonly property bool canPreview: root.fieldsFilled && root.poolResolved && root.poolExists + readonly property bool canSwap: root.canPreview && root.expectedOutNum > 0 && !root.swapInProgress + + readonly property string statusText: { + if (!root.backend) return qsTr("Wallet backend not ready."); + if (root.defAHex.length === 0 || root.defBHex.length === 0) return qsTr("Enter both token definition ids."); + 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 ""; + } + + readonly property string submitButtonText: { + if (root.swapInProgress) return qsTr("Submitting…"); + if (!root.fieldsFilled) return qsTr("Fill in all fields"); + if (!root.poolResolved) return qsTr("Resolving pool…"); + if (!root.poolExists) return qsTr("No pool / no liquidity"); + if (root.expectedOutNum <= 0) return qsTr("Amount too small"); + return qsTr("Swap"); + } + + function resetAmount() { + root.amountInText = ""; + } + + function doSwap() { + if (!root.backend || !root.canSwap) + return; + + root.swapInProgress = true; + root.swapError = ""; + + var minOutStr = root.formatDecimalInteger(root.minReceivedNum); + // Max u64 sentinel: "ignore deadline", per AmmUiBackend.rep. + var deadline = "18446744073709551615"; + + logos.watch(root.backend.swapExactInput(root.defAHex, root.defBHex, + root.holdingAHex, root.holdingBHex, + root.amountInText, minOutStr, + root.tokenDefInHex, deadline), + function (txHash) { + root.swapInProgress = false; + if (txHash && txHash.length > 0) { + root.swapSucceeded({ + "txHash": txHash, + "amountIn": root.amountInText, + "sellDefHex": root.tokenDefInHex, + "buyDefHex": root.tokenDefOutHex, + "minReceived": minOutStr + }); + root.resetAmount(); + 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: panelLayout.implicitHeight + 32 + + Behavior on color { ColorAnimation { duration: 300 } } + + ColumnLayout { + id: panelLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 16 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + Layout.fillWidth: true + text: qsTr("Manual swap (advanced)") + color: theme.colors.textPrimary + font.pixelSize: 16 + font.weight: Font.Medium + } + + Text { + text: root.expanded ? "▴" : "▾" + color: theme.colors.textSecondary + font.pixelSize: 14 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.expanded = !root.expanded + } + } + + Text { + Layout.fillWidth: true + visible: root.expanded + text: qsTr("Paste the real token definition ids and your own holding addresses for this pair. Executes a real on-chain swap.") + color: theme.colors.textSecondary + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.expanded + spacing: 10 + + // ── Direction toggle ────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + radius: 12 + color: root.direction === "aToB" ? theme.colors.ctaBg : theme.colors.panelBg + Text { + anchors.centerIn: parent + text: qsTr("Sell A → Buy B") + color: root.direction === "aToB" ? "#ffffff" : theme.colors.textSecondary + font.pixelSize: 12 + font.weight: Font.Medium + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.direction = "aToB" + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + radius: 12 + color: root.direction === "bToA" ? theme.colors.ctaBg : theme.colors.panelBg + Text { + anchors.centerIn: parent + text: qsTr("Sell B → Buy A") + color: root.direction === "bToA" ? "#ffffff" : theme.colors.textSecondary + font.pixelSize: 12 + font.weight: Font.Medium + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.direction = "bToA" + } + } + } + + // ── Manual fields ────────────────────────────────────────────── + ManualField { + Layout.fillWidth: true + theme: root.theme + label: qsTr("Token A definition id") + placeholder: qsTr("hex or base58") + text: root.defAHex + onEdited: function (v) { root.defAHex = v; } + } + + ManualField { + Layout.fillWidth: true + theme: root.theme + label: qsTr("Token B definition id") + placeholder: qsTr("hex or base58") + text: root.defBHex + onEdited: function (v) { root.defBHex = v; } + } + + ManualField { + Layout.fillWidth: true + theme: root.theme + label: qsTr("Your Token A holding address") + placeholder: qsTr("hex or base58") + text: root.holdingAHex + onEdited: function (v) { root.holdingAHex = v; } + } + + ManualField { + Layout.fillWidth: true + theme: root.theme + label: qsTr("Your Token B holding address") + placeholder: qsTr("hex or base58") + text: root.holdingBHex + onEdited: function (v) { root.holdingBHex = v; } + } + + ManualField { + Layout.fillWidth: true + theme: root.theme + label: qsTr("Amount in (base units)") + placeholder: qsTr("0") + text: root.amountInText + numericOnly: true + onEdited: function (v) { root.amountInText = v; } + } + + SlippageToleranceControl { + Layout.fillWidth: true + tolerancePercent: root.slippageTolerancePercent + onToleranceChangeRequested: function (tolerancePercent) { + root.slippageTolerancePercent = swapState.clampSlippagePercent(tolerancePercent); + } + } + + Text { + Layout.fillWidth: true + visible: root.statusText.length > 0 + text: root.statusText + color: (root.poolError.length > 0 || (root.poolResolved && !root.poolExists)) ? "#F08A76" : theme.colors.textSecondary + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + SwapSummary { + Layout.fillWidth: true + visible: root.canPreview && root.amountInNum > 0 + theme: root.theme + feeText: swapState.formatTokenAmount(root.feeAmountNum, "") + priceImpactText: swapState.formatPercent(root.priceImpactPercentValue) + priceImpactPercent: root.priceImpactPercentValue + slippageText: swapState.formatSlippagePercent(root.slippageTolerancePercent) + minReceivedText: swapState.formatTokenAmount(root.minReceivedNum, "") + qsTr(" (base units, est.)") + } + + Text { + Layout.fillWidth: true + visible: root.swapError.length > 0 + text: root.swapError + color: "#F08A76" + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + Rectangle { + id: ctaBox + Layout.fillWidth: true + Layout.preferredHeight: 48 + radius: 16 + color: !root.canSwap ? 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.canSwap ? "#ffffff" : theme.colors.textSecondary + font.pixelSize: 15 + font.weight: Font.Medium + } + + MouseArea { + id: ctaHover + anchors.fill: parent + hoverEnabled: true + enabled: root.canSwap + cursorShape: root.canSwap ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.doSwap() + } + } + } + } +} diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 75b42d4..3cd21f5 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -8,6 +8,11 @@ import "../state" Item { id: root + // Real backend replica (logos.module("amm_ui")), wired from Main.qml. + // Only used by ManualSwapPanel below — the dummy SwapCard above stays + // client-side/demo only until the token picker is backed by real data. + property var backend: null + property var tokens: [ { symbol: "TOK1", name: "Token 1", color: "#627eea", letter: "E", address: "0x0000000000000000000000000000000000000000", usdPrice: 2392.70, balance: 4.25, reserve: 850 }, { symbol: "TOK2", name: "Token 2", color: "#2775ca", letter: "$", address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", usdPrice: 1.00, balance: 12480, reserve: 2400000 }, @@ -107,6 +112,23 @@ Item { } } + ManualSwapPanel { + id: manualSwapPanel + Layout.alignment: Qt.AlignHCenter + theme: theme + backend: root.backend + width: swapCard.width + + onSwapSucceeded: function(snapshot) { + swapToast.show(qsTr("Swap submitted"), + qsTr("tx %1").arg(snapshot.txHash)) + } + + onSwapFailed: function(message) { + console.warn("Manual swap failed:", message) + } + } + Text { Layout.alignment: Qt.AlignHCenter text: "Buy and sell crypto on LEZ."