feat(amm/ui): add liquidity deposit preview (#61)

Fixes #61
This commit is contained in:
Ricardo Guilherme Schmidt 2026-04-22 18:38:36 -03:00 committed by r4bbit
parent e75a778f7a
commit 58a705c0bc
No known key found for this signature in database
GPG Key ID: E95F1E9447DC91A9
4 changed files with 390 additions and 9 deletions

View File

@ -180,15 +180,32 @@ Item {
color: "#151515" color: "#151515"
} }
ColumnLayout { Flickable {
anchors.fill: parent id: scroll
anchors.margins: 12
spacing: 10
PoolPositionSummary { anchors.fill: parent
poolState: poolState clip: true
Layout.fillWidth: true contentHeight: content.implicitHeight + 24
Layout.preferredHeight: implicitHeight contentWidth: width
ColumnLayout {
id: content
spacing: 10
width: scroll.width - 24
x: 12
y: 12
PoolPositionSummary {
poolState: poolState
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
}
AddLiquidityForm {
poolState: poolState
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
}
} }
} }
} }

View File

@ -0,0 +1,142 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import "../state"
Rectangle {
id: root
required property DummyPoolState poolState
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 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 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") : ""
color: "#1D1D1D"
implicitHeight: content.implicitHeight + 20
radius: 8
border.color: "#343434"
border.width: 1
function setAmounts(nextA, nextB, intentToken, showZero) {
root.lastEditedToken = intentToken;
root.amountA = nextA > 0 || showZero ? root.poolState.formatInputAmount(nextA) : "";
root.amountB = nextB > 0 || showZero ? root.poolState.formatInputAmount(nextB) : "";
}
function updateFromTokenA(value) {
if (value.length === 0) {
setAmounts(0, 0, "A", false);
return;
}
const nextA = root.poolState.parseAmount(value);
setAmounts(nextA, root.poolState.amountBForA(nextA), "A", true);
}
function updateFromTokenB(value) {
if (value.length === 0) {
setAmounts(0, 0, "B", false);
return;
}
const nextB = root.poolState.parseAmount(value);
setAmounts(root.poolState.amountAForB(nextB), nextB, "B", true);
}
function useMax(intentToken) {
const capped = root.poolState.maxAddLiquidityForBalances();
setAmounts(capped.actualA, capped.actualB, intentToken, false);
}
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 10
spacing: 10
Text {
color: "#E7E1D8"
font.bold: true
font.pixelSize: 16
text: qsTr("Add liquidity")
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Current ratio")
value: qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA)
Layout.fillWidth: true
}
TokenAmountInput {
balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceA, root.poolState.tokenA)
errorText: root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : ""
helperText: root.lastEditedToken === "B" && root.amountA.length > 0 ? qsTr("Calculated from current pool ratio") : ""
label: qsTr("Token A amount")
token: root.poolState.tokenA
text: root.amountA
Layout.fillWidth: true
onEditingChanged: function (value) {
root.updateFromTokenA(value);
}
onMaxClicked: root.useMax("A")
}
TokenAmountInput {
balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceB, root.poolState.tokenB)
errorText: root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : ""
helperText: root.lastEditedToken === "A" && root.amountB.length > 0 ? qsTr("Calculated from current pool ratio") : ""
label: qsTr("Token B amount")
token: root.poolState.tokenB
text: root.amountB
Layout.fillWidth: true
onEditingChanged: function (value) {
root.updateFromTokenB(value);
}
onMaxClicked: root.useMax("B")
}
SummaryRow {
label: qsTr("Required ratio")
value: qsTr("%1 %2 / 1 %3").arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA).arg(root.poolState.tokenB)
Layout.fillWidth: true
}
SummaryRow {
estimated: true
estimateHelp: qsTr("Estimated with the same integer floor math used by the add-liquidity contract path.")
label: qsTr("Estimated LP tokens")
value: root.poolState.formatLpAmount(root.preview.deltaLp)
Layout.fillWidth: true
}
Text {
color: "#F08A76"
font.pixelSize: 12
lineHeight: 1.25
text: root.warningText
visible: root.warningText.length > 0
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}

View File

@ -0,0 +1,156 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Rectangle {
id: root
property alias text: amountField.text
property string balance: ""
property string errorText: ""
property string helperText: ""
property string label: ""
property string token: ""
signal editingChanged(string value)
signal maxClicked
color: "#151515"
implicitHeight: content.implicitHeight + 20
radius: 8
border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434"
border.width: 1
Accessible.name: root.label
Accessible.role: Accessible.EditableText
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 10
spacing: 8
RowLayout {
spacing: 8
Layout.fillWidth: true
Text {
color: "#A9A098"
elide: Text.ElideRight
font.pixelSize: 12
text: root.label
Layout.fillWidth: true
}
Text {
color: "#E7E1D8"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 12
horizontalAlignment: Text.AlignRight
text: root.token
Layout.maximumWidth: 76
}
}
RowLayout {
spacing: 8
Layout.fillWidth: true
TextField {
id: amountField
activeFocusOnTab: true
color: "#E7E1D8"
font.bold: true
font.pixelSize: 18
inputMethodHints: Qt.ImhFormattedNumbersOnly
placeholderText: qsTr("0")
selectByMouse: true
selectedTextColor: "#151515"
selectionColor: "#F26A21"
validator: RegularExpressionValidator {
regularExpression: /[0-9]*([.][0-9]*)?/
}
Accessible.name: root.label
Layout.fillWidth: true
Layout.minimumHeight: 44
onTextEdited: root.editingChanged(text)
background: Rectangle {
border.color: amountField.activeFocus ? "#F26A21" : "#343434"
border.width: 1
color: amountField.activeFocus ? "#1F1B18" : "#101010"
radius: 6
}
}
Button {
id: maxButton
activeFocusOnTab: true
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("MAX")
Accessible.name: qsTr("Use maximum %1 balance").arg(root.token)
Layout.minimumHeight: 44
Layout.preferredWidth: 58
onClicked: root.maxClicked()
contentItem: Text {
color: maxButton.activeFocus || maxButton.hovered ? "#151515" : "#F26A21"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: maxButton.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: "#F26A21"
border.width: 1
color: maxButton.pressed ? "#D95C1E" : maxButton.hovered || maxButton.activeFocus ? "#F26A21" : "#201712"
radius: 6
}
}
}
RowLayout {
spacing: 8
Layout.fillWidth: true
Text {
color: root.errorText.length > 0 ? "#F08A76" : root.helperText.length > 0 ? "#F26A21" : "#A9A098"
elide: Text.ElideRight
font.pixelSize: 11
text: root.errorText.length > 0 ? root.errorText : root.helperText
visible: text.length > 0
Layout.fillWidth: true
}
Text {
color: "#A9A098"
elide: Text.ElideRight
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
text: qsTr("Balance %1").arg(root.balance)
Layout.alignment: Qt.AlignRight
Layout.maximumWidth: 150
}
}
}
}

View File

@ -10,10 +10,13 @@ QtObject {
property real reserveA: 1000000 property real reserveA: 1000000
property real reserveB: 500 property real reserveB: 500
property real totalLpSupply: 22360679 property real totalLpSupply: 22360679
property real walletBalanceA: 60000
property real walletBalanceB: 20
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
readonly property real userOwnedB: reserveB * poolShare readonly property real userOwnedB: reserveB * poolShare
readonly property real tokenAPerTokenB: reserveB > 0 ? Math.floor(reserveA / reserveB) : 0
function applyAddLiquidity(actualA, actualB, mintedLp) { function applyAddLiquidity(actualA, actualB, mintedLp) {
const safeA = Math.max(0, Number(actualA) || 0); const safeA = Math.max(0, Number(actualA) || 0);
@ -45,6 +48,55 @@ QtObject {
reserveA = 1000000; reserveA = 1000000;
reserveB = 500; reserveB = 500;
totalLpSupply = 22360679; totalLpSupply = 22360679;
walletBalanceA = 60000;
walletBalanceB = 20;
}
function parseAmount(value) {
return Math.max(0, Number(value) || 0);
}
function floorAmount(value) {
return Math.floor(parseAmount(value));
}
function amountBForA(amountA) {
if (reserveA <= 0) {
return 0;
}
return reserveB * parseAmount(amountA) / reserveA;
}
function amountAForB(amountB) {
if (reserveB <= 0) {
return 0;
}
return reserveA * parseAmount(amountB) / reserveB;
}
function addLiquidityPreview(maxA, maxB) {
const safeMaxA = parseAmount(maxA);
const safeMaxB = parseAmount(maxB);
const idealA = reserveB > 0 ? reserveA * safeMaxB / reserveB : 0;
const idealB = reserveA > 0 ? reserveB * safeMaxA / reserveA : 0;
const actualA = Math.min(idealA, safeMaxA);
const actualB = Math.min(idealB, safeMaxB);
const lpFromA = reserveA > 0 ? Math.floor(totalLpSupply * actualA / reserveA) : 0;
const lpFromB = reserveB > 0 ? Math.floor(totalLpSupply * actualB / reserveB) : 0;
return {
"actualA": actualA,
"actualB": actualB,
"deltaLp": Math.min(lpFromA, lpFromB),
"idealA": idealA,
"idealB": idealB
};
}
function maxAddLiquidityForBalances() {
return addLiquidityPreview(walletBalanceA, walletBalanceB);
} }
function formatInteger(value) { function formatInteger(value) {
@ -52,8 +104,22 @@ QtObject {
return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
} }
function formatDecimal(value) {
const amount = Number(value) || 0;
if (Math.abs(amount - Math.round(amount)) < 0.000001) {
return formatInteger(amount);
}
return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, "");
}
function formatInputAmount(value) {
return formatDecimal(value);
}
function formatTokenAmount(value, token) { function formatTokenAmount(value, token) {
return formatInteger(value) + " " + token; return formatDecimal(value) + " " + token;
} }
function formatLpAmount(value) { function formatLpAmount(value) {