feat(amm/ui): add remove-liquidity preview (#62)

Fixes #62
This commit is contained in:
Ricardo Guilherme Schmidt 2026-04-22 18:51:27 -03:00 committed by r4bbit
parent 67b1e501e8
commit bf9001c363
4 changed files with 584 additions and 2 deletions

View File

@ -168,9 +168,12 @@ Item {
// Liquidity view // Liquidity view
Item { Item {
id: liquidityView
anchors.fill: parent anchors.fill: parent
visible: navbar.currentIndex === 1 visible: navbar.currentIndex === 1
property int activeLiquidityTab: 0
DummyPoolState { DummyPoolState {
id: poolState id: poolState
} }
@ -201,10 +204,28 @@ Item {
Layout.preferredHeight: implicitHeight Layout.preferredHeight: implicitHeight
} }
AddLiquidityForm { LiquidityActionTabs {
poolState: poolState currentIndex: liquidityView.activeLiquidityTab
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: implicitHeight Layout.preferredHeight: implicitHeight
onTabRequested: function(index) {
liquidityView.activeLiquidityTab = index
}
}
AddLiquidityForm {
poolState: poolState
visible: liquidityView.activeLiquidityTab === 0
Layout.fillWidth: true
Layout.preferredHeight: visible ? implicitHeight : 0
}
RemoveLiquidityForm {
poolState: poolState
visible: liquidityView.activeLiquidityTab === 1
Layout.fillWidth: true
Layout.preferredHeight: visible ? implicitHeight : 0
} }
} }
} }

View File

@ -0,0 +1,91 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Rectangle {
id: root
property int currentIndex: 0
signal tabRequested(int index)
color: "#1D1D1D"
implicitHeight: 44
radius: 8
border.color: "#343434"
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 4
spacing: 4
Button {
id: addTab
activeFocusOnTab: true
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("Add liquidity")
Accessible.name: addTab.text
Accessible.role: Accessible.PageTab
Layout.fillHeight: true
Layout.fillWidth: true
onClicked: root.tabRequested(0)
contentItem: Text {
color: root.currentIndex === 0 || addTab.hovered || addTab.activeFocus ? "#151515" : "#A9A098"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
text: addTab.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: addTab.activeFocus ? "#F26A21" : root.currentIndex === 0 ? "#F26A21" : "#151515"
border.width: 1
color: addTab.pressed ? "#D95C1E" : root.currentIndex === 0 ? "#F26A21" : addTab.hovered || addTab.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
Button {
id: removeTab
activeFocusOnTab: true
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("Remove liquidity")
Accessible.name: removeTab.text
Accessible.role: Accessible.PageTab
Layout.fillHeight: true
Layout.fillWidth: true
onClicked: root.tabRequested(1)
contentItem: Text {
color: root.currentIndex === 1 || removeTab.hovered || removeTab.activeFocus ? "#151515" : "#A9A098"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
text: removeTab.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: removeTab.activeFocus ? "#F26A21" : root.currentIndex === 1 ? "#F26A21" : "#151515"
border.width: 1
color: removeTab.pressed ? "#D95C1E" : root.currentIndex === 1 ? "#F26A21" : removeTab.hovered || removeTab.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
}
}

View File

@ -0,0 +1,426 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import "../state"
Rectangle {
id: root
required property DummyPoolState poolState
property int burnAmount: 0
readonly property int maxBurnAmount: root.poolState.clampBurnAmount(root.poolState.userLpBalance)
readonly property bool hasLpTokens: root.maxBurnAmount > 0
readonly property int preset25Amount: root.poolState.burnAmountForPercent(25)
readonly property int preset50Amount: root.poolState.burnAmountForPercent(50)
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 string estimateHelp: qsTr("Estimated with the same integer floor math used by the remove-liquidity contract path.")
color: "#1D1D1D"
implicitHeight: content.implicitHeight + 20
radius: 8
border.color: "#343434"
border.width: 1
onMaxBurnAmountChanged: {
if (root.burnAmount > root.maxBurnAmount) {
root.setBurnAmount(root.maxBurnAmount);
}
}
function setBurnAmount(value) {
root.burnAmount = root.poolState.clampBurnAmount(value);
}
function setBurnPercent(percent) {
root.setBurnAmount(root.poolState.burnAmountForPercent(percent));
}
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 10
spacing: 10
Text {
color: "#E7E1D8"
font.bold: true
font.pixelSize: 16
text: qsTr("Remove liquidity")
Layout.fillWidth: true
}
Text {
color: "#A9A098"
font.pixelSize: 12
lineHeight: 1.25
text: qsTr("Burn LP tokens to withdraw your proportional share of both pool tokens.")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Text {
color: "#F26A21"
font.pixelSize: 12
text: qsTr("No LP tokens")
visible: !root.hasLpTokens
Layout.fillWidth: true
}
Text {
color: "#A9A098"
font.pixelSize: 12
lineHeight: 1.25
text: qsTr("Add liquidity first to receive LP tokens before removing from this pool.")
visible: !root.hasLpTokens
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Rectangle {
color: root.hasLpTokens ? "#151515" : "#121212"
radius: 8
border.color: burnField.activeFocus ? "#F26A21" : "#343434"
border.width: 1
Layout.fillWidth: true
Layout.preferredHeight: inputContent.implicitHeight + 20
ColumnLayout {
id: inputContent
anchors.fill: parent
anchors.margins: 10
spacing: 8
RowLayout {
spacing: 8
Layout.fillWidth: true
Text {
color: "#A9A098"
elide: Text.ElideRight
font.pixelSize: 12
text: qsTr("LP tokens to burn")
Layout.fillWidth: true
}
Text {
color: "#A9A098"
elide: Text.ElideRight
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
text: qsTr("Available LP: %1").arg(root.poolState.formatInteger(root.poolState.userLpBalance))
Layout.maximumWidth: 170
}
}
TextField {
id: burnField
activeFocusOnTab: root.hasLpTokens
color: "#E7E1D8"
enabled: root.hasLpTokens
font.bold: true
font.pixelSize: 18
inputMethodHints: Qt.ImhDigitsOnly
placeholderText: qsTr("0")
selectByMouse: true
selectedTextColor: "#151515"
selectionColor: "#F26A21"
text: root.burnAmount > 0 ? String(root.burnAmount) : ""
validator: RegularExpressionValidator {
regularExpression: /[0-9]*/
}
Accessible.name: qsTr("LP tokens to burn")
Layout.fillWidth: true
Layout.minimumHeight: 44
onTextEdited: root.setBurnAmount(text)
background: Rectangle {
border.color: burnField.activeFocus ? "#F26A21" : "#343434"
border.width: 1
color: burnField.activeFocus ? "#1F1B18" : "#101010"
radius: 6
}
}
}
}
RowLayout {
spacing: 6
Layout.fillWidth: true
Button {
id: preset25
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("25%")
Accessible.name: qsTr("Remove 25 percent")
Layout.fillWidth: true
Layout.minimumHeight: 44
onClicked: root.setBurnPercent(25)
contentItem: Text {
color: preset25.enabled && (preset25.hovered || preset25.activeFocus || root.preset25Amount === root.burnAmount) ? "#151515" : "#A9A098"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: preset25.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: preset25.activeFocus || root.preset25Amount === root.burnAmount ? "#F26A21" : "#343434"
border.width: 1
color: preset25.pressed ? "#D95C1E" : root.preset25Amount === root.burnAmount ? "#F26A21" : preset25.hovered || preset25.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
Button {
id: preset50
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("50%")
Accessible.name: qsTr("Remove 50 percent")
Layout.fillWidth: true
Layout.minimumHeight: 44
onClicked: root.setBurnPercent(50)
contentItem: Text {
color: preset50.enabled && (preset50.hovered || preset50.activeFocus || root.preset50Amount === root.burnAmount) ? "#151515" : "#A9A098"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: preset50.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: preset50.activeFocus || root.preset50Amount === root.burnAmount ? "#F26A21" : "#343434"
border.width: 1
color: preset50.pressed ? "#D95C1E" : root.preset50Amount === root.burnAmount ? "#F26A21" : preset50.hovered || preset50.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
Button {
id: preset75
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("75%")
Accessible.name: qsTr("Remove 75 percent")
Layout.fillWidth: true
Layout.minimumHeight: 44
onClicked: root.setBurnPercent(75)
contentItem: Text {
color: preset75.enabled && (preset75.hovered || preset75.activeFocus || root.preset75Amount === root.burnAmount) ? "#151515" : "#A9A098"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: preset75.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: preset75.activeFocus || root.preset75Amount === root.burnAmount ? "#F26A21" : "#343434"
border.width: 1
color: preset75.pressed ? "#D95C1E" : root.preset75Amount === root.burnAmount ? "#F26A21" : preset75.hovered || preset75.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
Button {
id: presetMax
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("MAX")
Accessible.name: qsTr("Remove maximum LP balance")
Layout.fillWidth: true
Layout.minimumHeight: 44
onClicked: root.setBurnAmount(root.maxBurnAmount)
contentItem: Text {
color: presetMax.enabled && (presetMax.hovered || presetMax.activeFocus || root.burnAmount === root.maxBurnAmount) ? "#151515" : "#A9A098"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: presetMax.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: presetMax.activeFocus || root.burnAmount === root.maxBurnAmount ? "#F26A21" : "#343434"
border.width: 1
color: presetMax.pressed ? "#D95C1E" : root.burnAmount === root.maxBurnAmount ? "#F26A21" : presetMax.hovered || presetMax.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
}
ColumnLayout {
spacing: 6
Layout.fillWidth: true
RowLayout {
spacing: 8
Layout.fillWidth: true
Text {
color: "#A9A098"
font.pixelSize: 12
text: qsTr("Pool share to remove")
Layout.fillWidth: true
}
Text {
color: "#E7E1D8"
font.bold: true
font.pixelSize: 12
horizontalAlignment: Text.AlignRight
text: root.poolState.formatPercent(root.removePercent)
Layout.maximumWidth: 72
}
}
Slider {
id: burnSlider
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
from: 0
stepSize: 1
to: 100
value: root.removePercent
Accessible.name: qsTr("Pool share to remove")
Accessible.role: Accessible.Slider
Layout.fillWidth: true
Layout.minimumHeight: 44
onMoved: root.setBurnPercent(Math.round(value))
background: Rectangle {
color: "#343434"
implicitHeight: 4
radius: 2
x: burnSlider.leftPadding
y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2
width: burnSlider.availableWidth
Rectangle {
color: burnSlider.enabled ? "#F26A21" : "#56504A"
height: parent.height
radius: 2
width: burnSlider.visualPosition * parent.width
}
}
handle: Rectangle {
border.color: burnSlider.activeFocus ? "#E7E1D8" : "#F26A21"
border.width: 1
color: burnSlider.enabled ? "#F26A21" : "#56504A"
height: 18
radius: 9
width: 18
x: burnSlider.leftPadding + burnSlider.visualPosition * (burnSlider.availableWidth - width)
y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2
}
}
}
SummaryRow {
estimated: true
estimateHelp: root.estimateHelp
label: qsTr("Withdraw %1").arg(root.poolState.tokenA)
value: root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA)
Layout.fillWidth: true
}
SummaryRow {
estimated: true
estimateHelp: root.estimateHelp
label: qsTr("Withdraw %1").arg(root.poolState.tokenB)
value: root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB)
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("New reserve A")
value: root.poolState.formatTokenAmount(root.preview.newReserveA, root.poolState.tokenA)
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("New reserve B")
value: root.poolState.formatTokenAmount(root.preview.newReserveB, root.poolState.tokenB)
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("New LP supply")
value: root.poolState.formatInteger(root.preview.newTotalLpSupply)
Layout.fillWidth: true
}
SummaryRow {
estimated: true
estimateHelp: root.estimateHelp
label: qsTr("New user share")
value: root.poolState.formatPoolShare(root.preview.newUserShare)
Layout.fillWidth: true
}
}
}

View File

@ -12,6 +12,7 @@ QtObject {
property real totalLpSupply: 22360679 property real totalLpSupply: 22360679
property real walletBalanceA: 60000 property real walletBalanceA: 60000
property real walletBalanceB: 20 property real walletBalanceB: 20
readonly property real minimumLiquidity: 1000
readonly property real poolShare: totalLpSupply > 0 ? userLpBalance / totalLpSupply : 0 readonly property real poolShare: totalLpSupply > 0 ? userLpBalance / totalLpSupply : 0
readonly property real userOwnedA: reserveA * poolShare readonly property real userOwnedA: reserveA * poolShare
@ -99,6 +100,39 @@ QtObject {
return addLiquidityPreview(walletBalanceA, walletBalanceB); return addLiquidityPreview(walletBalanceA, walletBalanceB);
} }
function clampBurnAmount(value) {
return Math.min(floorAmount(value), Math.max(0, floorAmount(userLpBalance)));
}
function burnAmountForPercent(percent) {
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
if (safePercent === 100) {
return clampBurnAmount(userLpBalance);
}
return clampBurnAmount(Math.floor(userLpBalance * safePercent / 100));
}
function removeLiquidityPreview(burnedLp) {
const safeBurnedLp = totalLpSupply > 0 ? Math.min(clampBurnAmount(burnedLp), floorAmount(totalLpSupply)) : 0;
const withdrawA = totalLpSupply > 0 ? Math.floor(reserveA * safeBurnedLp / totalLpSupply) : 0;
const withdrawB = totalLpSupply > 0 ? Math.floor(reserveB * safeBurnedLp / totalLpSupply) : 0;
const newTotalLpSupply = Math.max(0, floorAmount(totalLpSupply) - safeBurnedLp);
const newUserLpBalance = Math.max(0, floorAmount(userLpBalance) - safeBurnedLp);
return {
"burnedLp": safeBurnedLp,
"newReserveA": Math.max(0, reserveA - withdrawA),
"newReserveB": Math.max(0, reserveB - withdrawB),
"newTotalLpSupply": newTotalLpSupply,
"newUserLpBalance": newUserLpBalance,
"newUserShare": newTotalLpSupply > 0 ? newUserLpBalance / newTotalLpSupply : 0,
"withdrawA": withdrawA,
"withdrawB": withdrawB
};
}
function formatInteger(value) { function formatInteger(value) {
const rounded = Math.round(Number(value) || 0); const rounded = Math.round(Number(value) || 0);
return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
@ -129,4 +163,14 @@ QtObject {
function formatPoolShare(value) { function formatPoolShare(value) {
return "\u2248 " + (Math.max(0, Number(value) || 0) * 100).toFixed(2) + "%"; return "\u2248 " + (Math.max(0, Number(value) || 0) * 100).toFixed(2) + "%";
} }
function formatPercent(value) {
const amount = Math.max(0, Number(value) || 0);
if (Math.abs(amount - Math.round(amount)) < 0.000001) {
return Math.round(amount).toString() + "%";
}
return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "") + "%";
}
} }