From d92a61fd9b6f6bafed30927cae2314edfdbb22be Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Apr 2026 19:06:14 -0300 Subject: [PATCH] feat(amm/ui): add slippage min received controls (#63) Fixes #63 --- amm-ui/qml/Main.qml | 13 +- amm-ui/qml/components/AddLiquidityForm.qml | 33 ++ amm-ui/qml/components/RemoveLiquidityForm.qml | 41 +++ .../components/SlippageToleranceControl.qml | 281 ++++++++++++++++++ amm-ui/qml/state/DummyPoolState.qml | 11 + 5 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 amm-ui/qml/components/SlippageToleranceControl.qml diff --git a/amm-ui/qml/Main.qml b/amm-ui/qml/Main.qml index cbf16ec..0dece81 100644 --- a/amm-ui/qml/Main.qml +++ b/amm-ui/qml/Main.qml @@ -172,7 +172,8 @@ Item { anchors.fill: parent visible: navbar.currentIndex === 1 - property int activeLiquidityTab: 0 + property int activeLiquidityTab: 0 + property real slippageTolerancePercent: 0.5 DummyPoolState { id: poolState @@ -216,16 +217,26 @@ Item { AddLiquidityForm { poolState: poolState + slippageTolerancePercent: liquidityView.slippageTolerancePercent visible: liquidityView.activeLiquidityTab === 0 Layout.fillWidth: true Layout.preferredHeight: visible ? implicitHeight : 0 + + onSlippageToleranceChangeRequested: function(tolerancePercent) { + liquidityView.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent) + } } RemoveLiquidityForm { poolState: poolState + slippageTolerancePercent: liquidityView.slippageTolerancePercent visible: liquidityView.activeLiquidityTab === 1 Layout.fillWidth: true Layout.preferredHeight: visible ? implicitHeight : 0 + + onSlippageToleranceChangeRequested: function(tolerancePercent) { + liquidityView.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent) + } } } } diff --git a/amm-ui/qml/components/AddLiquidityForm.qml b/amm-ui/qml/components/AddLiquidityForm.qml index 814815b..01a5a27 100644 --- a/amm-ui/qml/components/AddLiquidityForm.qml +++ b/amm-ui/qml/components/AddLiquidityForm.qml @@ -7,19 +7,24 @@ Rectangle { required property DummyPoolState poolState + property real slippageTolerancePercent: 0.5 property string amountA: "" property string amountB: "" property string lastEditedToken: "A" readonly property real parsedA: root.poolState.parseAmount(root.amountA) readonly property real parsedB: root.poolState.parseAmount(root.amountB) readonly property var preview: root.poolState.addLiquidityPreview(root.parsedA, root.parsedB) + readonly property int minLpReceived: root.poolState.minReceivedAmount(root.preview.deltaLp, root.slippageTolerancePercent) readonly property bool hasAnyAmount: root.parsedA > 0 || root.parsedB > 0 readonly property bool amountAOverBalance: root.parsedA > root.poolState.walletBalanceA readonly property bool amountBOverBalance: root.parsedB > root.poolState.walletBalanceB + readonly property bool minReceivedIsZero: root.hasAnyAmount && root.minLpReceived === 0 readonly property bool zeroTokenDeposit: root.hasAnyAmount && (root.preview.actualA === 0 || root.preview.actualB === 0) readonly property bool zeroLpDeposit: root.preview.actualA > 0 && root.preview.actualB > 0 && root.preview.deltaLp === 0 readonly property string warningText: root.zeroTokenDeposit ? qsTr("Deposit would be rejected because one token amount rounds to zero") : root.zeroLpDeposit ? qsTr("Deposit would mint 0 LP tokens") : "" + signal slippageToleranceChangeRequested(real tolerancePercent) + color: "#1D1D1D" implicitHeight: content.implicitHeight + 20 radius: 8 @@ -128,6 +133,34 @@ Rectangle { Layout.fillWidth: true } + SlippageToleranceControl { + tolerancePercent: root.slippageTolerancePercent + + Layout.fillWidth: true + + onToleranceChangeRequested: function (tolerancePercent) { + root.slippageToleranceChangeRequested(tolerancePercent); + } + } + + SummaryRow { + label: qsTr("Min LP received") + value: root.poolState.formatLpAmount(root.minLpReceived) + + Layout.fillWidth: true + } + + Text { + color: "#F08A76" + font.pixelSize: 12 + lineHeight: 1.25 + text: qsTr("Minimum received is 0. Increase amount or lower slippage.") + visible: root.minReceivedIsZero + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + Text { color: "#F08A76" font.pixelSize: 12 diff --git a/amm-ui/qml/components/RemoveLiquidityForm.qml b/amm-ui/qml/components/RemoveLiquidityForm.qml index 76284ef..9032bc1 100644 --- a/amm-ui/qml/components/RemoveLiquidityForm.qml +++ b/amm-ui/qml/components/RemoveLiquidityForm.qml @@ -8,6 +8,7 @@ Rectangle { required property DummyPoolState poolState + property real slippageTolerancePercent: 0.5 property int burnAmount: 0 readonly property int maxBurnAmount: root.poolState.clampBurnAmount(root.poolState.userLpBalance) readonly property bool hasLpTokens: root.maxBurnAmount > 0 @@ -16,8 +17,13 @@ Rectangle { readonly property int preset75Amount: root.poolState.burnAmountForPercent(75) readonly property real removePercent: root.maxBurnAmount > 0 ? root.burnAmount * 100 / root.maxBurnAmount : 0 readonly property var preview: root.poolState.removeLiquidityPreview(root.burnAmount) + readonly property int minTokenAReceived: root.poolState.minReceivedAmount(root.preview.withdrawA, root.slippageTolerancePercent) + readonly property int minTokenBReceived: root.poolState.minReceivedAmount(root.preview.withdrawB, root.slippageTolerancePercent) + readonly property bool minReceivedIsZero: root.burnAmount > 0 && (root.minTokenAReceived === 0 || root.minTokenBReceived === 0) readonly property string estimateHelp: qsTr("Estimated with the same integer floor math used by the remove-liquidity contract path.") + signal slippageToleranceChangeRequested(real tolerancePercent) + color: "#1D1D1D" implicitHeight: content.implicitHeight + 20 radius: 8 @@ -393,6 +399,41 @@ Rectangle { Layout.fillWidth: true } + SlippageToleranceControl { + tolerancePercent: root.slippageTolerancePercent + + Layout.fillWidth: true + + onToleranceChangeRequested: function (tolerancePercent) { + root.slippageToleranceChangeRequested(tolerancePercent); + } + } + + SummaryRow { + label: qsTr("Min %1 received").arg(root.poolState.tokenA) + value: root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA) + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Min %1 received").arg(root.poolState.tokenB) + value: root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB) + + Layout.fillWidth: true + } + + Text { + color: "#F08A76" + font.pixelSize: 12 + lineHeight: 1.25 + text: qsTr("Minimum received is 0. Increase amount or lower slippage.") + visible: root.minReceivedIsZero + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + SummaryRow { label: qsTr("New reserve A") value: root.poolState.formatTokenAmount(root.preview.newReserveA, root.poolState.tokenA) diff --git a/amm-ui/qml/components/SlippageToleranceControl.qml b/amm-ui/qml/components/SlippageToleranceControl.qml new file mode 100644 index 0000000..92a7fc8 --- /dev/null +++ b/amm-ui/qml/components/SlippageToleranceControl.qml @@ -0,0 +1,281 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Rectangle { + id: root + + property real tolerancePercent: 0.5 + property string customText: "" + readonly property string thresholdText: root.tolerancePercent <= 1 ? qsTr("Standard slippage") : root.tolerancePercent <= 5 ? qsTr("Higher slippage") : qsTr("High slippage risk") + readonly property string thresholdIcon: root.tolerancePercent <= 1 ? "i" : root.tolerancePercent <= 5 ? "!" : "!!" + + signal toleranceChangeRequested(real tolerancePercent) + + color: "#151515" + implicitHeight: content.implicitHeight + 20 + radius: 8 + border.color: customField.activeFocus ? "#F26A21" : "#343434" + border.width: 1 + + Component.onCompleted: root.restoreCustomText() + + onTolerancePercentChanged: { + if (!customField.activeFocus) { + root.restoreCustomText(); + } + } + + function formatTolerance(value) { + const amount = Number(value) || 0; + return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, ""); + } + + function restoreCustomText() { + root.customText = root.formatTolerance(root.tolerancePercent); + } + + function clampTolerance(value) { + return Math.max(0.01, Math.min(50, Number(value) || 0)); + } + + function commitPreset(value) { + const nextValue = root.clampTolerance(value); + root.customText = root.formatTolerance(nextValue); + root.toleranceChangeRequested(nextValue); + } + + function commitCustom() { + const parsed = Number(root.customText); + + if (root.customText.length === 0 || !isFinite(parsed) || parsed < 0) { + root.restoreCustomText(); + return; + } + + root.commitPreset(parsed); + } + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + Text { + color: "#A9A098" + font.pixelSize: 12 + text: qsTr("Slippage tolerance") + + Layout.fillWidth: true + } + + RowLayout { + spacing: 6 + + Layout.fillWidth: true + + Button { + id: preset01 + + readonly property real presetValue: 0.1 + readonly property bool selected: Math.abs(root.tolerancePercent - presetValue) < 0.000001 + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("0.1%") + + Accessible.name: qsTr("Set slippage tolerance to 0.1 percent") + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.commitPreset(presetValue) + + contentItem: Text { + color: preset01.hovered || preset01.activeFocus || preset01.selected ? "#151515" : "#A9A098" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: preset01.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: preset01.activeFocus || preset01.selected ? "#F26A21" : "#343434" + border.width: 1 + color: preset01.pressed ? "#D95C1E" : preset01.selected ? "#F26A21" : preset01.hovered || preset01.activeFocus ? "#E7E1D8" : "#101010" + radius: 6 + } + } + + Button { + id: preset05 + + readonly property real presetValue: 0.5 + readonly property bool selected: Math.abs(root.tolerancePercent - presetValue) < 0.000001 + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("0.5%") + + Accessible.name: qsTr("Set slippage tolerance to 0.5 percent") + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.commitPreset(presetValue) + + contentItem: Text { + color: preset05.hovered || preset05.activeFocus || preset05.selected ? "#151515" : "#A9A098" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: preset05.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: preset05.activeFocus || preset05.selected ? "#F26A21" : "#343434" + border.width: 1 + color: preset05.pressed ? "#D95C1E" : preset05.selected ? "#F26A21" : preset05.hovered || preset05.activeFocus ? "#E7E1D8" : "#101010" + radius: 6 + } + } + + Button { + id: preset10 + + readonly property real presetValue: 1.0 + readonly property bool selected: Math.abs(root.tolerancePercent - presetValue) < 0.000001 + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("1.0%") + + Accessible.name: qsTr("Set slippage tolerance to 1.0 percent") + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.commitPreset(presetValue) + + contentItem: Text { + color: preset10.hovered || preset10.activeFocus || preset10.selected ? "#151515" : "#A9A098" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: preset10.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: preset10.activeFocus || preset10.selected ? "#F26A21" : "#343434" + border.width: 1 + color: preset10.pressed ? "#D95C1E" : preset10.selected ? "#F26A21" : preset10.hovered || preset10.activeFocus ? "#E7E1D8" : "#101010" + radius: 6 + } + } + + Rectangle { + color: customField.activeFocus ? "#1F1B18" : "#101010" + radius: 6 + border.color: customField.activeFocus ? "#F26A21" : "#343434" + border.width: 1 + + Layout.minimumHeight: 44 + Layout.preferredWidth: 88 + + RowLayout { + spacing: 4 + + anchors { + fill: parent + leftMargin: 8 + rightMargin: 8 + } + + TextField { + id: customField + + activeFocusOnTab: true + color: "#E7E1D8" + font.bold: true + font.pixelSize: 12 + horizontalAlignment: Text.AlignRight + inputMethodHints: Qt.ImhFormattedNumbersOnly + placeholderText: qsTr("0.5") + selectByMouse: true + selectedTextColor: "#151515" + selectionColor: "#F26A21" + text: root.customText + validator: RegularExpressionValidator { + regularExpression: /[0-9]*([.][0-9]*)?/ + } + + Accessible.name: qsTr("Custom slippage tolerance percent") + + Layout.fillWidth: true + Layout.minimumHeight: 42 + + onEditingFinished: root.commitCustom() + onTextEdited: root.customText = text + Keys.onEscapePressed: { + root.restoreCustomText(); + customField.focus = false; + } + + background: Item {} + } + + Text { + color: "#A9A098" + font.bold: true + font.pixelSize: 12 + text: qsTr("%") + verticalAlignment: Text.AlignVCenter + + Layout.preferredWidth: 10 + } + } + } + } + + RowLayout { + spacing: 6 + + Layout.fillWidth: true + + Text { + color: root.tolerancePercent <= 1 ? "#8FD6A4" : root.tolerancePercent <= 5 ? "#F2B366" : "#F08A76" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: root.thresholdIcon + + Layout.preferredWidth: 18 + } + + Text { + color: root.tolerancePercent <= 1 ? "#8FD6A4" : root.tolerancePercent <= 5 ? "#F2B366" : "#F08A76" + font.pixelSize: 11 + text: root.thresholdText + + Layout.fillWidth: true + } + } + + Text { + color: "#A9A098" + font.pixelSize: 11 + text: qsTr("Allowed range: 0.01% to 50%.") + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + } +} diff --git a/amm-ui/qml/state/DummyPoolState.qml b/amm-ui/qml/state/DummyPoolState.qml index 98f854d..6874e6f 100644 --- a/amm-ui/qml/state/DummyPoolState.qml +++ b/amm-ui/qml/state/DummyPoolState.qml @@ -104,6 +104,17 @@ QtObject { return Math.min(floorAmount(value), Math.max(0, floorAmount(userLpBalance))); } + function clampSlippageTolerancePercent(value) { + return Math.max(0.01, Math.min(50, Number(value) || 0)); + } + + function minReceivedAmount(previewAmount, slippageTolerancePercent) { + const safeAmount = floorAmount(previewAmount); + const safeSlippage = clampSlippageTolerancePercent(slippageTolerancePercent); + + return Math.floor(safeAmount * (1 - safeSlippage / 100)); + } + function burnAmountForPercent(percent) { const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));