feat(amm/ui): add slippage min received controls (#63)

Fixes #63
This commit is contained in:
Ricardo Guilherme Schmidt 2026-04-22 19:06:14 -03:00 committed by r4bbit
parent bf9001c363
commit d92a61fd9b
5 changed files with 378 additions and 1 deletions

View File

@ -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)
}
}
}
}

View File

@ -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

View File

@ -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)

View File

@ -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
}
}
}

View File

@ -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));