diff --git a/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml b/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml new file mode 100644 index 0000000..e07c782 --- /dev/null +++ b/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml @@ -0,0 +1,230 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +FocusScope { + id: root + + property var snapshot: ({}) + property bool open: false + + signal canceled + signal confirmed(var snapshot) + + visible: root.open + z: 20 + + Keys.onEscapePressed: root.cancel() + + function openWithSnapshot(nextSnapshot) { + root.snapshot = nextSnapshot; + root.open = true; + root.forceActiveFocus(); + cancelButton.forceActiveFocus(); + } + + function cancel() { + root.open = false; + root.canceled(); + } + + function confirm() { + const confirmedSnapshot = root.snapshot; + root.open = false; + root.confirmed(confirmedSnapshot); + } + + Rectangle { + anchors.fill: parent + color: "#99000000" + + MouseArea { + anchors.fill: parent + } + } + + Rectangle { + id: panel + + anchors.centerIn: parent + color: "#1D1D1D" + implicitHeight: dialogContent.implicitHeight + 24 + radius: 8 + width: Math.max(0, Math.min(420, root.width - 32)) + border.color: "#343434" + border.width: 1 + + ColumnLayout { + id: dialogContent + + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 16 + text: qsTr("Confirm new position") + + Layout.fillWidth: true + } + + Rectangle { + color: "#151515" + radius: 8 + border.color: "#343434" + border.width: 1 + + Layout.fillWidth: true + Layout.preferredHeight: summaryLayout.implicitHeight + 20 + + ColumnLayout { + id: summaryLayout + + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + SummaryRow { + label: qsTr("Pair") + value: root.snapshot.pairText || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Instruction") + value: root.snapshot.instructionText || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Fee tier") + value: root.snapshot.feeLabel || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") + value: root.snapshot.depositA || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") + value: root.snapshot.depositB || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Expected LP") + value: root.snapshot.expectedLp || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Minimum LP") + value: root.snapshot.minimumLp || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Quote hash") + value: root.snapshot.shortQuoteHash || "" + + Layout.fillWidth: true + } + } + } + + Text { + color: "#A9A098" + font.pixelSize: 12 + lineHeight: 1.25 + text: qsTr("Submit will re-quote against current wallet and chain state before dispatch.") + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + + RowLayout { + spacing: 8 + + Layout.fillWidth: true + + Button { + id: cancelButton + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("Cancel") + + Accessible.name: cancelButton.text + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.cancel() + + contentItem: Text { + color: cancelButton.hovered || cancelButton.activeFocus ? "#151515" : "#E7E1D8" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + text: cancelButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: cancelButton.activeFocus ? "#F26A21" : "#343434" + border.width: 1 + color: cancelButton.pressed ? "#343434" : cancelButton.hovered || cancelButton.activeFocus ? "#E7E1D8" : "#151515" + radius: 6 + } + } + + Button { + id: confirmButton + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("Submit") + + Accessible.name: confirmButton.text + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.confirm() + + contentItem: Text { + color: "#151515" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + text: confirmButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: "#F26A21" + border.width: 1 + color: confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" + radius: 6 + } + } + } + } + } +} diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml new file mode 100644 index 0000000..4019def --- /dev/null +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -0,0 +1,1168 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import "../shared" +import "../../state" + +Rectangle { + id: root + + required property NewPositionPrototypeBackend prototypeBackend + + property int selectedTokenAIndex: 0 + property int selectedTokenBIndex: 1 + property int selectedFeeBps: 30 + property real slippageTolerancePercent: 0.5 + property string amountA: "" + property string amountB: "" + property string editedSide: "A" + property string initialPrice: "2500" + property int depositScale: 1 + property string submitError: "" + + readonly property var emptyToken: ({ + "symbol": "", + "balance": 0, + "balanceText": "", + "accent": "#343434" + }) + readonly property var holdings: root.prototypeBackend ? root.prototypeBackend.holdings : [] + readonly property var tokenA: root.holdings[root.selectedTokenAIndex] || root.emptyToken + readonly property var tokenB: root.holdings[root.selectedTokenBIndex] || root.emptyToken + readonly property var poolContext: root.prototypeBackend.poolContext(root.tokenA.symbol, root.tokenB.symbol) + readonly property string poolStatus: root.poolContext.poolStatus + readonly property bool activePool: root.poolStatus === "active_pool" + readonly property bool missingPool: root.poolStatus === "missing_pool" + readonly property int slippageBps: Math.round(root.slippageTolerancePercent * 100) + readonly property var quote: root.prototypeBackend.quoteNewPosition(root.buildRequest()) + readonly property bool canConfirm: root.quote.status === "ok" + readonly property bool compact: root.width < 820 + readonly property bool hasActiveInput: root.amountA.length > 0 || root.amountB.length > 0 + readonly property bool showQuoteError: root.quote.status === "error" && (root.poolStatus === "unavailable_pool" || root.missingPool || root.hasActiveInput) + + signal confirmationRequested(var snapshot) + + color: "#1B1B1B" + implicitHeight: content.implicitHeight + 24 + radius: 12 + border.color: "#303030" + border.width: 1 + + Component.onCompleted: root.afterPairChanged() + + onSelectedTokenAIndexChanged: root.afterPairChanged() + onSelectedTokenBIndexChanged: root.afterPairChanged() + onPoolStatusChanged: root.normalizeFeeSelection() + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + + RowLayout { + spacing: 12 + + Layout.fillWidth: true + + ColumnLayout { + spacing: 2 + + Layout.fillWidth: true + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 18 + text: qsTr("New position") + + Layout.fillWidth: true + } + + Text { + color: "#A9A098" + elide: Text.ElideRight + font.pixelSize: 12 + text: qsTr("Active account %1").arg(root.prototypeBackend.activeAccount) + + Layout.fillWidth: true + } + } + + Rectangle { + color: "#211914" + radius: 12 + border.color: "#49301F" + border.width: 1 + + Layout.preferredHeight: 28 + Layout.preferredWidth: pairText.implicitWidth + 20 + + Text { + id: pairText + + anchors.centerIn: parent + color: "#F2D8C7" + font.bold: true + font.pixelSize: 12 + text: qsTr("%1 / %2").arg(root.tokenA.symbol).arg(root.tokenB.symbol) + } + } + } + + Rectangle { + color: root.statusBackgroundColor() + radius: 8 + border.color: root.statusBorderColor() + border.width: 1 + + Layout.fillWidth: true + Layout.preferredHeight: statusRow.implicitHeight + 18 + + RowLayout { + id: statusRow + + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + Rectangle { + color: root.statusColor() + radius: 5 + + Layout.preferredHeight: 10 + Layout.preferredWidth: 10 + } + + ColumnLayout { + spacing: 2 + + Layout.fillWidth: true + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 13 + text: root.quote.statusLabel + + Layout.fillWidth: true + } + + Text { + color: "#A9A098" + font.pixelSize: 12 + lineHeight: 1.2 + text: root.quote.statusDetail + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + } + } + } + + GridLayout { + columns: 1 + columnSpacing: 12 + rowSpacing: 12 + + Layout.fillWidth: true + + Rectangle { + color: "#151515" + radius: 8 + border.color: "#303030" + border.width: 1 + + Layout.fillWidth: true + Layout.preferredHeight: selectionContent.implicitHeight + 20 + + ColumnLayout { + id: selectionContent + + anchors.fill: parent + anchors.margins: 10 + spacing: 12 + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 14 + text: qsTr("Pair") + + Layout.fillWidth: true + } + + GridLayout { + columns: root.compact ? 1 : 2 + columnSpacing: 8 + rowSpacing: 8 + + Layout.fillWidth: true + + ColumnLayout { + spacing: 6 + + Layout.fillWidth: true + + Text { + color: "#A9A098" + font.pixelSize: 12 + text: qsTr("Token A") + + Layout.fillWidth: true + } + + Repeater { + model: root.prototypeBackend.holdings + + delegate: Button { + id: tokenAButton + + readonly property var holding: modelData + readonly property bool selected: root.selectedTokenAIndex === index + readonly property bool duplicate: root.selectedTokenBIndex === index + + activeFocusOnTab: true + enabled: !duplicate + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: holding.symbol + + Accessible.name: qsTr("Select %1 as token A").arg(holding.symbol) + + Layout.fillWidth: true + Layout.minimumHeight: 48 + + onClicked: root.chooseToken("A", index) + + contentItem: RowLayout { + spacing: 8 + + Rectangle { + color: tokenAButton.holding.accent + radius: 5 + + Layout.preferredHeight: 10 + Layout.preferredWidth: 10 + } + + ColumnLayout { + spacing: 1 + + Layout.fillWidth: true + + Text { + color: tokenAButton.enabled ? "#E7E1D8" : "#6E6862" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 12 + text: tokenAButton.holding.symbol + + Layout.fillWidth: true + } + + Text { + color: tokenAButton.enabled ? "#A9A098" : "#5D5751" + elide: Text.ElideRight + font.pixelSize: 10 + text: tokenAButton.holding.balanceText + + Layout.fillWidth: true + } + } + } + + background: Rectangle { + border.color: tokenAButton.activeFocus || tokenAButton.selected ? "#F26A21" : "#343434" + border.width: 1 + color: tokenAButton.selected ? "#211914" : tokenAButton.hovered || tokenAButton.activeFocus ? "#202020" : "#101010" + radius: 6 + } + } + } + } + + ColumnLayout { + spacing: 6 + + Layout.fillWidth: true + + Text { + color: "#A9A098" + font.pixelSize: 12 + text: qsTr("Token B") + + Layout.fillWidth: true + } + + Repeater { + model: root.prototypeBackend.holdings + + delegate: Button { + id: tokenBButton + + readonly property var holding: modelData + readonly property bool selected: root.selectedTokenBIndex === index + readonly property bool duplicate: root.selectedTokenAIndex === index + + activeFocusOnTab: true + enabled: !duplicate + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: holding.symbol + + Accessible.name: qsTr("Select %1 as token B").arg(holding.symbol) + + Layout.fillWidth: true + Layout.minimumHeight: 48 + + onClicked: root.chooseToken("B", index) + + contentItem: RowLayout { + spacing: 8 + + Rectangle { + color: tokenBButton.holding.accent + radius: 5 + + Layout.preferredHeight: 10 + Layout.preferredWidth: 10 + } + + ColumnLayout { + spacing: 1 + + Layout.fillWidth: true + + Text { + color: tokenBButton.enabled ? "#E7E1D8" : "#6E6862" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 12 + text: tokenBButton.holding.symbol + + Layout.fillWidth: true + } + + Text { + color: tokenBButton.enabled ? "#A9A098" : "#5D5751" + elide: Text.ElideRight + font.pixelSize: 10 + text: tokenBButton.holding.balanceText + + Layout.fillWidth: true + } + } + } + + background: Rectangle { + border.color: tokenBButton.activeFocus || tokenBButton.selected ? "#F26A21" : "#343434" + border.width: 1 + color: tokenBButton.selected ? "#211914" : tokenBButton.hovered || tokenBButton.activeFocus ? "#202020" : "#101010" + radius: 6 + } + } + } + } + } + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 14 + text: qsTr("Fee tier") + + Layout.fillWidth: true + } + + GridLayout { + columns: root.compact ? 2 : 3 + columnSpacing: 6 + rowSpacing: 6 + + Layout.fillWidth: true + + Repeater { + model: root.prototypeBackend.feeTiers + + delegate: Rectangle { + id: feeChip + + readonly property var tier: modelData + readonly property string disabledReason: root.feeTierDisabledReason(tier) + readonly property bool chipEnabled: disabledReason.length === 0 + readonly property bool selected: root.selectedFeeBps === tier.bps + + activeFocusOnTab: chipEnabled + color: selected ? "#211914" : feeMouse.containsMouse && chipEnabled ? "#202020" : "#101010" + radius: 6 + border.color: selected || activeFocus ? "#F26A21" : chipEnabled ? "#343434" : "#2A2A2A" + border.width: 1 + opacity: chipEnabled ? 1 : 0.58 + + Accessible.name: qsTr("Fee tier %1").arg(tier.label) + Accessible.role: Accessible.Button + Accessible.description: disabledReason + + Layout.fillWidth: true + Layout.minimumHeight: 42 + + Keys.onSpacePressed: { + if (chipEnabled) + root.selectFeeTier(tier.bps); + } + Keys.onReturnPressed: { + if (chipEnabled) + root.selectFeeTier(tier.bps); + } + + Text { + anchors.centerIn: parent + color: feeChip.selected ? "#F2D8C7" : feeChip.chipEnabled ? "#E7E1D8" : "#7D756E" + font.bold: true + font.pixelSize: 12 + text: feeChip.tier.label + } + + MouseArea { + id: feeMouse + + anchors.fill: parent + cursorShape: feeChip.chipEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + hoverEnabled: true + + onClicked: { + if (feeChip.chipEnabled) { + feeChip.forceActiveFocus(); + root.selectFeeTier(feeChip.tier.bps); + } + } + } + + ToolTip.delay: 300 + ToolTip.text: disabledReason + ToolTip.visible: feeMouse.containsMouse && disabledReason.length > 0 + } + } + } + + SummaryRow { + label: qsTr("Pool id") + value: root.quote.pool.id.length > 0 ? root.quote.pool.id : "-" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Pool price") + value: root.quote.pool.priceText + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Reserves") + value: root.quote.pool.reserveText + + Layout.fillWidth: true + } + } + } + + Rectangle { + color: "#151515" + radius: 8 + border.color: "#303030" + border.width: 1 + + Layout.fillWidth: true + Layout.preferredHeight: depositContent.implicitHeight + 20 + + ColumnLayout { + id: depositContent + + anchors.fill: parent + anchors.margins: 10 + spacing: 12 + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 14 + text: root.activePool ? qsTr("Ratio-locked deposit") : root.missingPool ? qsTr("Initial price deposit") : qsTr("Deposit unavailable") + + Layout.fillWidth: true + } + + ColumnLayout { + spacing: 10 + visible: root.activePool + + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + + TokenAmountInput { + balance: root.tokenA.balanceText + errorText: root.showQuoteError && root.editedSide === "A" ? root.quote.error : "" + helperText: root.editedSide === "B" && root.amountA.length > 0 ? qsTr("Locked by pool ratio") : "" + label: qsTr("%1 deposit").arg(root.tokenA.symbol) + token: root.tokenA.symbol + text: root.amountA + + Layout.fillWidth: true + + onEditingChanged: function (value) { + root.editActiveAmount("A", value); + } + onMaxClicked: root.useMaxActive("A") + } + + TokenAmountInput { + balance: root.tokenB.balanceText + errorText: root.showQuoteError && root.editedSide === "B" ? root.quote.error : "" + helperText: root.editedSide === "A" && root.amountB.length > 0 ? qsTr("Locked by pool ratio") : "" + label: qsTr("%1 deposit").arg(root.tokenB.symbol) + token: root.tokenB.symbol + text: root.amountB + + Layout.fillWidth: true + + onEditingChanged: function (value) { + root.editActiveAmount("B", value); + } + onMaxClicked: root.useMaxActive("B") + } + } + + ColumnLayout { + spacing: 10 + visible: root.missingPool + + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + + Text { + color: "#A9A098" + font.pixelSize: 12 + lineHeight: 1.2 + text: qsTr("Price is locked before deposit sizing. Scaling preserves the same initial price.") + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + + Rectangle { + color: priceField.activeFocus ? "#1F1B18" : "#101010" + radius: 6 + border.color: priceField.activeFocus ? "#F26A21" : "#343434" + border.width: 1 + + Layout.fillWidth: true + Layout.minimumHeight: 64 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 4 + + Text { + color: "#A9A098" + font.pixelSize: 12 + text: qsTr("Initial price: 1 %1 in %2").arg(root.tokenB.symbol).arg(root.tokenA.symbol) + + Layout.fillWidth: true + } + + TextField { + id: priceField + + activeFocusOnTab: true + color: "#E7E1D8" + font.bold: true + font.pixelSize: 18 + inputMethodHints: Qt.ImhFormattedNumbersOnly + placeholderText: qsTr("0") + selectByMouse: true + selectedTextColor: "#151515" + selectionColor: "#F26A21" + text: root.initialPrice + validator: RegularExpressionValidator { + regularExpression: /[0-9]*([.][0-9]*)?/ + } + + Accessible.name: qsTr("Initial pool price") + + Layout.fillWidth: true + + onTextEdited: { + root.submitError = ""; + root.initialPrice = text; + } + + background: Item {} + } + } + } + + RowLayout { + spacing: 6 + + Layout.fillWidth: true + + Repeater { + model: [1, 2, 5] + + delegate: Button { + id: scaleButton + + readonly property int scaleValue: modelData + readonly property bool selected: root.depositScale === scaleValue + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("%1x").arg(scaleValue) + + Accessible.name: qsTr("Scale deposit to %1 times minimum").arg(scaleValue) + + Layout.fillWidth: true + Layout.minimumHeight: 42 + + onClicked: { + root.submitError = ""; + root.depositScale = scaleValue; + } + + contentItem: Text { + color: scaleButton.selected ? "#F2D8C7" : scaleButton.hovered || scaleButton.activeFocus ? "#E7E1D8" : "#A9A098" + font.bold: true + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + text: scaleButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: scaleButton.activeFocus || scaleButton.selected ? "#F26A21" : "#343434" + border.width: 1 + color: scaleButton.selected ? "#211914" : scaleButton.hovered || scaleButton.activeFocus ? "#202020" : "#101010" + radius: 6 + } + } + } + } + + TokenAmountInput { + balance: root.tokenA.balanceText + helperText: qsTr("Minimum deposit at locked price") + label: qsTr("%1 deposit").arg(root.tokenA.symbol) + readOnly: true + showMaxButton: false + token: root.tokenA.symbol + text: root.quote.deposit.maxA.input + + Layout.fillWidth: true + } + + TokenAmountInput { + balance: root.tokenB.balanceText + helperText: qsTr("Scaled with %1").arg(root.tokenA.symbol) + label: qsTr("%1 deposit").arg(root.tokenB.symbol) + readOnly: true + showMaxButton: false + token: root.tokenB.symbol + text: root.quote.deposit.maxB.input + + Layout.fillWidth: true + } + } + + Rectangle { + color: "#211914" + radius: 8 + border.color: "#49301F" + border.width: 1 + visible: root.showQuoteError || root.submitError.length > 0 + + Layout.fillWidth: true + Layout.preferredHeight: visible ? errorText.implicitHeight + 20 : 0 + + Text { + id: errorText + + anchors.fill: parent + anchors.margins: 10 + color: "#F08A76" + font.pixelSize: 12 + lineHeight: 1.2 + text: root.submitError.length > 0 ? root.submitError : root.quote.error + wrapMode: Text.WordWrap + } + } + + SlippageToleranceControl { + tolerancePercent: root.slippageTolerancePercent + visible: root.activePool || root.missingPool + + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + + onToleranceChangeRequested: function (tolerancePercent) { + root.submitError = ""; + root.slippageTolerancePercent = Math.max(0.01, Math.min(50, Number(tolerancePercent) || 0)); + } + } + + Button { + id: confirmButton + + activeFocusOnTab: true + enabled: root.canConfirm + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: root.canConfirm ? qsTr("Preview and confirm") : qsTr("Preview unavailable") + + Accessible.name: confirmButton.text + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.confirmationRequested(root.submitSnapshot()) + + contentItem: Text { + color: confirmButton.enabled ? "#151515" : "#7D756E" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + text: confirmButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: confirmButton.enabled ? "#F26A21" : "#343434" + border.width: 1 + color: confirmButton.enabled ? confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818" + radius: 6 + } + } + } + } + } + + Rectangle { + color: "#151515" + radius: 8 + border.color: "#303030" + border.width: 1 + + Layout.fillWidth: true + Layout.preferredHeight: previewContent.implicitHeight + 20 + + ColumnLayout { + id: previewContent + + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + RowLayout { + spacing: 8 + + Layout.fillWidth: true + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 14 + text: qsTr("Preview") + + Layout.fillWidth: true + } + + Text { + color: root.quote.status === "ok" ? "#8FD6A4" : "#F08A76" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + text: root.quote.status === "ok" ? qsTr("Ready") : qsTr("Needs input") + + Layout.maximumWidth: 120 + } + } + + GridLayout { + columns: root.compact ? 1 : 2 + columnSpacing: 16 + rowSpacing: 8 + + Layout.fillWidth: true + + ColumnLayout { + spacing: 8 + + Layout.fillWidth: true + + SummaryRow { + label: qsTr("Instruction") + value: root.instructionText() + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Deposit %1").arg(root.tokenA.symbol) + value: root.quote.deposit.actualA.display + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Deposit %1").arg(root.tokenB.symbol) + value: root.quote.deposit.actualB.display + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Expected LP") + value: root.quote.lp.expected.display + estimated: true + estimateHelp: qsTr("Prototype quote mirrors the future backend response shape.") + + Layout.fillWidth: true + } + } + + ColumnLayout { + spacing: 8 + + Layout.fillWidth: true + + SummaryRow { + label: qsTr("Minimum LP") + value: root.quote.lp.minimum.display + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Locked LP") + value: root.quote.lp.locked.display + visible: root.missingPool + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Current position") + value: root.quote.position.userLp + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Pool share") + value: root.quote.position.share + + Layout.fillWidth: true + } + } + } + + Rectangle { + color: "#101010" + radius: 8 + border.color: "#303030" + border.width: 1 + visible: root.quote.accountChanges.length > 0 + + Layout.fillWidth: true + Layout.preferredHeight: visible ? accountList.implicitHeight + 16 : 0 + + ColumnLayout { + id: accountList + + anchors.fill: parent + anchors.margins: 8 + spacing: 6 + + Text { + color: "#A9A098" + font.pixelSize: 12 + text: qsTr("Account changes") + + Layout.fillWidth: true + } + + Repeater { + model: root.quote.accountChanges + + delegate: Rectangle { + id: accountRow + + readonly property var accountChange: modelData + + color: "#151515" + radius: 6 + border.color: "#2C2C2C" + border.width: 1 + + Layout.fillWidth: true + Layout.preferredHeight: rowLayout.implicitHeight + 12 + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + + Rectangle { + color: root.accountActionColor(accountRow.accountChange.action) + radius: 4 + + Layout.preferredHeight: 8 + Layout.preferredWidth: 8 + } + + Text { + color: "#E7E1D8" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 12 + text: accountRow.accountChange.role + + Layout.preferredWidth: 120 + } + + Text { + color: "#A9A098" + elide: Text.ElideMiddle + font.pixelSize: 11 + text: accountRow.accountChange.id + + Layout.fillWidth: true + } + + Text { + color: "#F2D8C7" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + text: accountRow.accountChange.action + + Layout.preferredWidth: 100 + } + } + } + } + } + } + } + } + } + + function buildRequest() { + return { + "amountA": root.amountA, + "amountB": root.amountB, + "depositScale": root.depositScale, + "editedSide": root.editedSide, + "feeBps": root.selectedFeeBps, + "initialPrice": root.initialPrice, + "slippageBps": root.slippageBps, + "tokenA": root.tokenA.symbol, + "tokenB": root.tokenB.symbol + }; + } + + function chooseToken(side, index) { + root.submitError = ""; + + if (side === "A") { + root.selectedTokenAIndex = index; + if (root.selectedTokenBIndex === index) + root.selectedTokenBIndex = root.nextTokenIndex(index); + return; + } + + root.selectedTokenBIndex = index; + if (root.selectedTokenAIndex === index) + root.selectedTokenAIndex = root.nextTokenIndex(index); + } + + function nextTokenIndex(index) { + return root.holdings.length > 0 ? (index + 1) % root.holdings.length : 0; + } + + function afterPairChanged() { + if (root.selectedTokenAIndex === root.selectedTokenBIndex) + root.selectedTokenBIndex = root.nextTokenIndex(root.selectedTokenAIndex); + + if (root.tokenA.symbol.length === 0 || root.tokenB.symbol.length === 0) + return; + + root.submitError = ""; + root.amountA = ""; + root.amountB = ""; + root.editedSide = "A"; + root.initialPrice = root.prototypeBackend.formatAmount(root.prototypeBackend.defaultInitialPrice(root.tokenA.symbol, root.tokenB.symbol)); + root.depositScale = 1; + root.normalizeFeeSelection(); + } + + function normalizeFeeSelection() { + if (root.tokenA.symbol.length === 0 || root.tokenB.symbol.length === 0) + return; + + const context = root.prototypeBackend.poolContext(root.tokenA.symbol, root.tokenB.symbol); + + if (context.poolStatus === "active_pool") { + root.selectedFeeBps = context.storedFeeBps; + return; + } + + if (root.selectedFeeBps === 25) + root.selectedFeeBps = 30; + } + + function selectFeeTier(bps) { + root.submitError = ""; + root.selectedFeeBps = bps; + } + + function feeTierDisabledReason(tier) { + if (!tier.supported) + return qsTr("Unsupported by the AMM program. Valid fee tiers are 0.01%, 0.05%, 0.30%, and 1.00%."); + + if (root.poolStatus === "active_pool" && tier.bps !== root.poolContext.storedFeeBps) + return qsTr("Existing pool uses %1. Fee tier is fixed by pool configuration.").arg(root.prototypeBackend.feeLabel(root.poolContext.storedFeeBps)); + + if (root.poolStatus === "unavailable_pool") + return qsTr("Fee selection is disabled until this pair can be quoted."); + + return ""; + } + + function editActiveAmount(side, value) { + root.submitError = ""; + root.editedSide = side; + + if (side === "A") + root.amountA = value; + else + root.amountB = value; + + root.syncActiveCounterpart(); + } + + function syncActiveCounterpart() { + const nextQuote = root.prototypeBackend.quoteNewPosition(root.buildRequest()); + + if (nextQuote.status !== "ok" || nextQuote.poolStatus !== "active_pool") + return; + + if (root.editedSide === "A") + root.amountB = nextQuote.deposit.maxB.input; + else + root.amountA = nextQuote.deposit.maxA.input; + } + + function useMaxActive(side) { + const ratio = root.prototypeBackend.activeRatio(root.tokenA.symbol, root.tokenB.symbol); + const maxA = Math.min(root.tokenA.balance, root.tokenB.balance / ratio); + const maxB = Math.min(root.tokenB.balance, root.tokenA.balance * ratio); + + if (side === "A") + root.editActiveAmount("A", root.prototypeBackend.formatAmount(maxA)); + else + root.editActiveAmount("B", root.prototypeBackend.formatAmount(maxB)); + } + + function setSubmitError(message) { + root.submitError = message; + } + + function resetAfterSubmit() { + root.submitError = ""; + + if (root.activePool) { + root.amountA = ""; + root.amountB = ""; + root.editedSide = "A"; + } + } + + function submitSnapshot() { + return { + "depositA": root.quote.deposit.actualA.display, + "depositB": root.quote.deposit.actualB.display, + "expectedLp": root.quote.lp.expected.display, + "feeLabel": root.quote.feeLabel, + "instructionText": root.instructionText(), + "minimumLp": root.quote.lp.minimum.display, + "pairText": qsTr("%1 / %2").arg(root.tokenA.symbol).arg(root.tokenB.symbol), + "quote": root.quote, + "quoteHash": root.quote.quoteHash, + "request": root.buildRequest(), + "shortQuoteHash": root.quote.quoteHash.substring(0, 18), + "tokenA": root.tokenA.symbol, + "tokenB": root.tokenB.symbol + }; + } + + function instructionText() { + if (root.quote.instruction === "new_definition") + return qsTr("Create pool"); + + if (root.quote.instruction === "add_liquidity") + return qsTr("Add liquidity"); + + return qsTr("Unavailable"); + } + + function statusColor() { + if (root.poolStatus === "active_pool") + return "#8FD6A4"; + + if (root.poolStatus === "missing_pool") + return "#F2B366"; + + return "#F08A76"; + } + + function statusBackgroundColor() { + if (root.poolStatus === "active_pool") + return "#162218"; + + if (root.poolStatus === "missing_pool") + return "#211914"; + + return "#241817"; + } + + function statusBorderColor() { + if (root.poolStatus === "active_pool") + return "#2E5A39"; + + if (root.poolStatus === "missing_pool") + return "#49301F"; + + return "#5A3028"; + } + + function accountActionColor(action) { + if (action === qsTr("Create")) + return "#F2B366"; + + if (action === qsTr("Read")) + return "#8BA8E8"; + + return "#8FD6A4"; + } +} diff --git a/apps/amm/qml/components/liquidity/TokenAmountInput.qml b/apps/amm/qml/components/liquidity/TokenAmountInput.qml index 120be57..05600e4 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -10,6 +10,8 @@ Rectangle { property string errorText: "" property string helperText: "" property string label: "" + property bool readOnly: false + property bool showMaxButton: true property string token: "" signal editingChanged(string value) @@ -71,6 +73,7 @@ Rectangle { font.pixelSize: 18 inputMethodHints: Qt.ImhFormattedNumbersOnly placeholderText: qsTr("0") + readOnly: root.readOnly selectByMouse: true selectedTextColor: "#151515" selectionColor: "#F26A21" @@ -83,7 +86,10 @@ Rectangle { Layout.fillWidth: true Layout.minimumHeight: 44 - onTextEdited: root.editingChanged(text) + onTextEdited: { + if (!root.readOnly) + root.editingChanged(text); + } background: Rectangle { border.color: amountField.activeFocus ? "#F26A21" : "#343434" @@ -97,14 +103,16 @@ Rectangle { id: maxButton activeFocusOnTab: true + enabled: root.showMaxButton && !root.readOnly focusPolicy: Qt.StrongFocus hoverEnabled: true text: qsTr("MAX") + visible: root.showMaxButton Accessible.name: qsTr("Use maximum %1 balance").arg(root.token) Layout.minimumHeight: 44 - Layout.preferredWidth: 58 + Layout.preferredWidth: visible ? 58 : 0 onClicked: root.maxClicked() diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index f471952..675a6fd 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -1,5 +1,4 @@ import QtQuick 2.15 -import QtQuick.Layouts 1.15 import "../components/shared" import "../components/liquidity" import "../state" @@ -7,19 +6,17 @@ import "../state" Item { id: root - property int activeLiquidityTab: 0 - property real slippageTolerancePercent: 0.5 readonly property int pageMargin: 16 - readonly property int preferredCardWidth: 492 - readonly property int pageCardY: pageCard.implicitHeight + root.pageMargin * 2 <= scroll.height ? Math.round((scroll.height - pageCard.implicitHeight) / 2) : root.pageMargin + readonly property int preferredCardWidth: 960 + readonly property int pageCardY: newPositionForm.implicitHeight + root.pageMargin * 2 <= scroll.height ? Math.max(root.pageMargin, Math.round((scroll.height - newPositionForm.implicitHeight) / 4)) : root.pageMargin width: parent ? parent.width : implicitWidth height: parent ? parent.height : implicitHeight implicitWidth: root.preferredCardWidth + root.pageMargin * 2 - implicitHeight: pageCard.implicitHeight + root.pageMargin * 2 + implicitHeight: newPositionForm.implicitHeight + root.pageMargin * 2 - DummyPoolState { - id: poolState + NewPositionPrototypeBackend { + id: newPositionBackend } Rectangle { @@ -32,158 +29,57 @@ Item { anchors.fill: parent clip: true - contentHeight: Math.max(height, pageCard.y + pageCard.implicitHeight + root.pageMargin) + contentHeight: Math.max(height, newPositionForm.y + newPositionForm.implicitHeight + root.pageMargin) contentWidth: width - enabled: !confirmationDialog.visible + enabled: !confirmationDialog.open flickableDirection: Flickable.VerticalFlick - Rectangle { - id: pageCard + NewPositionForm { + id: newPositionForm - color: "#1B1B1B" - implicitHeight: shellContent.implicitHeight + 24 - radius: 16 - border.color: "#303030" - border.width: 1 + prototypeBackend: newPositionBackend width: Math.max(0, Math.min(scroll.width - root.pageMargin * 2, root.preferredCardWidth)) x: Math.max(root.pageMargin, (scroll.width - width) / 2) y: root.pageCardY - ColumnLayout { - id: shellContent - - anchors.fill: parent - anchors.margins: 12 - spacing: 10 - - RowLayout { - spacing: 10 - - Layout.fillWidth: true - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 18 - text: qsTr("Liquidity") - - Layout.fillWidth: true - } - - Rectangle { - color: "#211914" - radius: 12 - border.color: "#49301F" - border.width: 1 - - Layout.preferredHeight: 26 - Layout.preferredWidth: pairText.implicitWidth + 20 - - Text { - id: pairText - - anchors.centerIn: parent - color: "#F2D8C7" - font.bold: true - font.pixelSize: 12 - text: qsTr("%1 / %2").arg(poolState.tokenA).arg(poolState.tokenB) - } - } - } - - LiquidityActionTabs { - currentIndex: root.activeLiquidityTab - - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - - onTabRequested: function (index) { - root.activeLiquidityTab = index; - } - } - - PoolPositionSummary { - poolState: poolState - - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - } - - AddLiquidityForm { - id: addLiquidityForm - - poolState: poolState - slippageTolerancePercent: root.slippageTolerancePercent - visible: root.activeLiquidityTab === 0 - - Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 - - onSlippageToleranceChangeRequested: function (tolerancePercent) { - root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent); - } - - onAddLiquidityRequested: function (snapshot) { - confirmationDialog.openWithSnapshot(snapshot); - } - } - - RemoveLiquidityForm { - id: removeLiquidityForm - - poolState: poolState - slippageTolerancePercent: root.slippageTolerancePercent - visible: root.activeLiquidityTab === 1 - - Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 - - onSlippageToleranceChangeRequested: function (tolerancePercent) { - root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent); - } - - onRemoveLiquidityRequested: function (snapshot) { - confirmationDialog.openWithSnapshot(snapshot); - } - } - } - - SuccessToast { - id: successToast - - width: Math.max(0, Math.min(380, parent.width - 24)) - - anchors { - bottom: parent.bottom - bottomMargin: 14 - horizontalCenter: parent.horizontalCenter - } + onConfirmationRequested: function (snapshot) { + confirmationDialog.openWithSnapshot(snapshot); } } } - LiquidityConfirmationDialog { + SuccessToast { + id: successToast + + width: Math.max(0, Math.min(380, root.width - 32)) + z: 30 + + anchors { + bottom: parent.bottom + bottomMargin: 18 + horizontalCenter: parent.horizontalCenter + } + } + + NewPositionConfirmationDialog { id: confirmationDialog anchors.fill: parent onConfirmed: function (snapshot) { - root.confirmLiquidityAction(snapshot); + root.confirmNewPosition(snapshot); } } - function confirmLiquidityAction(snapshot) { - if (snapshot.action === "add") { - poolState.applyAddLiquidity(snapshot.actualA, snapshot.actualB, snapshot.deltaLp); - addLiquidityForm.resetForm(); - successToast.show(qsTr("Liquidity added"), qsTr("Position updated")); + function confirmNewPosition(snapshot) { + const result = newPositionBackend.submitNewPosition(snapshot.request, snapshot.quoteHash); + + if (result.status !== "ok") { + newPositionForm.setSubmitError(result.error); return; } - if (snapshot.action === "remove") { - poolState.applyRemoveLiquidity(snapshot.withdrawA, snapshot.withdrawB, snapshot.burnAmount); - removeLiquidityForm.resetForm(); - successToast.show(qsTr("Liquidity removed"), qsTr("Position updated")); - } + newPositionForm.resetAfterSubmit(); + successToast.show(result.message, result.detail); } } diff --git a/apps/amm/qml/state/NewPositionPrototypeBackend.qml b/apps/amm/qml/state/NewPositionPrototypeBackend.qml new file mode 100644 index 0000000..f81f221 --- /dev/null +++ b/apps/amm/qml/state/NewPositionPrototypeBackend.qml @@ -0,0 +1,468 @@ +import QtQuick 2.15 + +QtObject { + id: root + + readonly property string activeAccount: "lz1q9s...42fd" + readonly property var holdings: [ + { + "symbol": "USDC", + "name": "USD Coin", + "definitionId": "token:usdc-testnet", + "holdingId": "holding:active:usdc", + "balance": 12450, + "balanceText": "12,450.00 USDC", + "accent": "#2E7CF6" + }, + { + "symbol": "LOGOS", + "name": "Logos", + "definitionId": "token:logos-testnet", + "holdingId": "holding:active:logos", + "balance": 850000, + "balanceText": "850,000 LOGOS", + "accent": "#F26A21" + }, + { + "symbol": "WETH", + "name": "Wrapped Ether", + "definitionId": "token:weth-testnet", + "holdingId": "holding:active:weth", + "balance": 3.25, + "balanceText": "3.25 WETH", + "accent": "#B7C2D8" + } + ] + readonly property var feeTiers: [ + { + "bps": 1, + "label": "0.01%", + "supported": true + }, + { + "bps": 5, + "label": "0.05%", + "supported": true + }, + { + "bps": 25, + "label": "0.25%", + "supported": false + }, + { + "bps": 30, + "label": "0.30%", + "supported": true + }, + { + "bps": 100, + "label": "1.00%", + "supported": true + } + ] + readonly property real minimumLiquidity: 1000 + + function holdingBySymbol(symbol) { + for (let i = 0; i < root.holdings.length; ++i) { + if (root.holdings[i].symbol === symbol) + return root.holdings[i]; + } + + return null; + } + + function feeTierByBps(bps) { + for (let i = 0; i < root.feeTiers.length; ++i) { + if (root.feeTiers[i].bps === bps) + return root.feeTiers[i]; + } + + return null; + } + + function feeLabel(bps) { + const tier = feeTierByBps(bps); + return tier ? tier.label : qsTr("%1 bps").arg(bps); + } + + function parseAmount(value) { + const parsed = Number(value); + return isFinite(parsed) && parsed > 0 ? parsed : 0; + } + + function formatAmount(value) { + const amount = Math.max(0, Number(value) || 0); + + if (amount >= 1000) + return amount.toFixed(2).replace(/\.00$/, "").replace(/\B(?=(\d{3})+(?!\d))/g, ","); + + if (amount >= 1) + return amount.toFixed(4).replace(/0+$/, "").replace(/[.]$/, ""); + + return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, ""); + } + + function formatTokenAmount(value, symbol) { + return qsTr("%1 %2").arg(formatAmount(value)).arg(symbol); + } + + function unorderedPairKey(symbolA, symbolB) { + return symbolA < symbolB ? symbolA + "/" + symbolB : symbolB + "/" + symbolA; + } + + function poolContext(symbolA, symbolB) { + if (!symbolA || !symbolB || symbolA === symbolB) { + return { + "poolStatus": "unavailable_pool", + "statusLabel": qsTr("Unavailable"), + "detail": qsTr("Choose two different assets from the active account."), + "instruction": "", + "storedFeeBps": 0, + "poolId": "", + "priceText": "", + "reserveText": "" + }; + } + + const key = unorderedPairKey(symbolA, symbolB); + + if (key === "LOGOS/USDC") { + return { + "poolStatus": "active_pool", + "statusLabel": qsTr("Active pool"), + "detail": qsTr("Deposits are quoted against the existing pool ratio. Nonmatching fee tiers are locked."), + "instruction": "add_liquidity", + "storedFeeBps": 30, + "poolId": "pool:usdc-logos", + "priceText": qsTr("1 USDC = 8 LOGOS"), + "reserveText": qsTr("1,250,000 USDC / 10,000,000 LOGOS") + }; + } + + if (key === "USDC/WETH") { + return { + "poolStatus": "missing_pool", + "statusLabel": qsTr("Missing pool"), + "detail": qsTr("Set the initial price first, then scale both deposits together."), + "instruction": "new_definition", + "storedFeeBps": 0, + "poolId": "pool:usdc-weth", + "priceText": qsTr("No reserves yet"), + "reserveText": qsTr("Pool account is empty") + }; + } + + return { + "poolStatus": "unavailable_pool", + "statusLabel": qsTr("Unavailable"), + "detail": qsTr("A pool account exists, but it cannot be quoted safely for this prototype state."), + "instruction": "", + "storedFeeBps": 0, + "poolId": "pool:logos-weth", + "priceText": qsTr("Quote disabled"), + "reserveText": qsTr("Unsupported stored pool state") + }; + } + + function activeQuote(request, context) { + const tokenA = holdingBySymbol(request.tokenA); + const tokenB = holdingBySymbol(request.tokenB); + const inputA = parseAmount(request.amountA); + const inputB = parseAmount(request.amountB); + const editA = request.editedSide !== "B"; + const amountA = editA ? inputA : inputB / activeRatio(request.tokenA, request.tokenB); + const amountB = editA ? inputA * activeRatio(request.tokenA, request.tokenB) : inputB; + const expectedLp = Math.floor(Math.min(amountA * 5.5, amountB * 0.69)); + const minLp = Math.floor(expectedLp * (10000 - request.slippageBps) / 10000); + + if (inputA <= 0 && inputB <= 0) + return quoteError(context, request, qsTr("Enter a deposit amount to preview LP output.")); + + if (amountA > tokenA.balance) + return quoteError(context, request, qsTr("Insufficient %1 balance.").arg(tokenA.symbol)); + + if (amountB > tokenB.balance) + return quoteError(context, request, qsTr("Insufficient %1 balance.").arg(tokenB.symbol)); + + if (minLp <= 0) + return quoteError(context, request, qsTr("LP minimum rounds to zero. Increase deposit amount.")); + + return quoteOk(context, request, { + "maxA": amountA, + "maxB": amountB, + "actualA": amountA, + "actualB": amountB, + "expectedLp": expectedLp, + "minimumLp": minLp, + "lockedLp": 0, + "position": { + "userLp": "148,320 LP", + "share": "1.18%", + "ownedA": request.tokenA === "USDC" ? "14,750 USDC" : "118,000 LOGOS", + "ownedB": request.tokenB === "LOGOS" ? "118,000 LOGOS" : "14,750 USDC" + }, + "accountChanges": [ + { + "role": qsTr("Config"), + "id": "amm:config", + "action": qsTr("Read") + }, + { + "role": qsTr("Pool"), + "id": context.poolId, + "action": qsTr("Update") + }, + { + "role": qsTr("Vault A"), + "id": "vault:" + request.tokenA.toLowerCase(), + "action": qsTr("Update") + }, + { + "role": qsTr("Vault B"), + "id": "vault:" + request.tokenB.toLowerCase(), + "action": qsTr("Update") + }, + { + "role": qsTr("User LP holding"), + "id": "holding:active:lp", + "action": qsTr("Update or create") + }, + { + "role": qsTr("Clock"), + "id": "clock:canonical", + "action": qsTr("Read") + } + ] + }); + } + + function missingQuote(request, context) { + const tokenA = holdingBySymbol(request.tokenA); + const tokenB = holdingBySymbol(request.tokenB); + const price = parseAmount(request.initialPrice) || defaultInitialPrice(request.tokenA, request.tokenB); + const scale = Math.max(1, Number(request.depositScale) || 1); + const amountA = price >= 1 ? price * scale : scale; + const amountB = price >= 1 ? scale : scale / price; + const expectedLp = Math.floor(Math.sqrt(amountA * amountB) * 48); + const userLp = Math.max(0, expectedLp - root.minimumLiquidity); + const minLp = Math.floor(userLp * (10000 - request.slippageBps) / 10000); + + if (amountA > tokenA.balance) + return quoteError(context, request, qsTr("Initial deposit exceeds %1 balance.").arg(tokenA.symbol)); + + if (amountB > tokenB.balance) + return quoteError(context, request, qsTr("Initial deposit exceeds %1 balance.").arg(tokenB.symbol)); + + if (userLp <= 0) + return quoteError(context, request, qsTr("Deposit must mint more than the locked minimum liquidity.")); + + return quoteOk(context, request, { + "maxA": amountA, + "maxB": amountB, + "actualA": amountA, + "actualB": amountB, + "expectedLp": userLp, + "minimumLp": minLp, + "lockedLp": root.minimumLiquidity, + "position": { + "userLp": "0 LP", + "share": "New pool", + "ownedA": "0 " + request.tokenA, + "ownedB": "0 " + request.tokenB + }, + "accountChanges": [ + { + "role": qsTr("Config"), + "id": "amm:config", + "action": qsTr("Read") + }, + { + "role": qsTr("Pool"), + "id": context.poolId, + "action": qsTr("Create") + }, + { + "role": qsTr("Vault A"), + "id": "vault:" + request.tokenA.toLowerCase(), + "action": qsTr("Update or create") + }, + { + "role": qsTr("Vault B"), + "id": "vault:" + request.tokenB.toLowerCase(), + "action": qsTr("Update or create") + }, + { + "role": qsTr("LP definition"), + "id": "lp:" + context.poolId, + "action": qsTr("Create") + }, + { + "role": qsTr("LP lock holding"), + "id": "holding:lp-lock", + "action": qsTr("Create") + }, + { + "role": qsTr("User LP holding"), + "id": "holding:active:lp", + "action": qsTr("Update or create") + }, + { + "role": qsTr("Current tick"), + "id": "twap:" + context.poolId, + "action": qsTr("Create") + }, + { + "role": qsTr("Clock"), + "id": "clock:canonical", + "action": qsTr("Read") + } + ] + }); + } + + function quoteNewPosition(request) { + const context = poolContext(request.tokenA, request.tokenB); + const tier = feeTierByBps(request.feeBps); + + if (!tier || !tier.supported) + return quoteError(context, request, qsTr("Fee tier is not supported by the AMM program.")); + + if (context.poolStatus === "active_pool" && request.feeBps !== context.storedFeeBps) + return quoteError(context, request, qsTr("Existing pool uses %1.").arg(feeLabel(context.storedFeeBps))); + + if (context.poolStatus === "active_pool") + return activeQuote(request, context); + + if (context.poolStatus === "missing_pool") + return missingQuote(request, context); + + return quoteError(context, request, context.detail); + } + + function submitNewPosition(request, quoteHash) { + const quote = quoteNewPosition(request); + + if (quote.status !== "ok") { + return { + "status": "error", + "error": quote.error + }; + } + + if (quote.quoteHash !== quoteHash) { + return { + "status": "error", + "error": qsTr("Quote changed. Refresh preview before submitting.") + }; + } + + return { + "status": "ok", + "message": quote.instruction === "new_definition" ? qsTr("Pool creation submitted") : qsTr("Liquidity deposit submitted"), + "detail": qsTr("%1 / %2").arg(request.tokenA).arg(request.tokenB) + }; + } + + function quoteOk(context, request, amounts) { + return { + "status": "ok", + "error": "", + "poolStatus": context.poolStatus, + "statusLabel": context.statusLabel, + "statusDetail": context.detail, + "instruction": context.instruction, + "feeBps": request.feeBps, + "feeLabel": feeLabel(request.feeBps), + "quoteHash": quoteHash(request), + "pool": { + "id": context.poolId, + "priceText": context.poolStatus === "missing_pool" ? qsTr("1 %1 = %2 %3").arg(request.tokenB).arg(formatAmount(parseAmount(request.initialPrice) || defaultInitialPrice(request.tokenA, request.tokenB))).arg(request.tokenA) : context.priceText, + "reserveText": context.reserveText + }, + "deposit": { + "maxA": amountValue(amounts.maxA, request.tokenA), + "maxB": amountValue(amounts.maxB, request.tokenB), + "actualA": amountValue(amounts.actualA, request.tokenA), + "actualB": amountValue(amounts.actualB, request.tokenB) + }, + "lp": { + "expected": amountValue(amounts.expectedLp, "LP"), + "minimum": amountValue(amounts.minimumLp, "LP"), + "locked": amountValue(amounts.lockedLp, "LP") + }, + "position": amounts.position, + "accountChanges": amounts.accountChanges + }; + } + + function quoteError(context, request, errorText) { + return { + "status": "error", + "error": errorText, + "poolStatus": context.poolStatus, + "statusLabel": context.statusLabel, + "statusDetail": context.detail, + "instruction": context.instruction, + "feeBps": request.feeBps, + "feeLabel": feeLabel(request.feeBps), + "quoteHash": quoteHash(request), + "pool": { + "id": context.poolId, + "priceText": context.priceText, + "reserveText": context.reserveText + }, + "deposit": { + "maxA": amountValue(0, request.tokenA), + "maxB": amountValue(0, request.tokenB), + "actualA": amountValue(0, request.tokenA), + "actualB": amountValue(0, request.tokenB) + }, + "lp": { + "expected": amountValue(0, "LP"), + "minimum": amountValue(0, "LP"), + "locked": amountValue(context.poolStatus === "missing_pool" ? root.minimumLiquidity : 0, "LP") + }, + "position": { + "userLp": "0 LP", + "share": "-", + "ownedA": "0 " + request.tokenA, + "ownedB": "0 " + request.tokenB + }, + "accountChanges": [] + }; + } + + function amountValue(value, symbol) { + const amount = Math.max(0, Number(value) || 0); + return { + "value": amount, + "input": formatAmount(amount), + "display": formatTokenAmount(amount, symbol), + "symbol": symbol + }; + } + + function activeRatio(symbolA, symbolB) { + if (symbolA === "USDC" && symbolB === "LOGOS") + return 8; + + if (symbolA === "LOGOS" && symbolB === "USDC") + return 0.125; + + return 1; + } + + function defaultInitialPrice(symbolA, symbolB) { + if (symbolA === "USDC" && symbolB === "WETH") + return 2500; + + if (symbolA === "WETH" && symbolB === "USDC") + return 0.0004; + + return 1; + } + + function quoteHash(request) { + return "demo-" + request.tokenA + "-" + request.tokenB + "-" + request.feeBps + "-" + request.editedSide + "-" + request.amountA + "-" + request.amountB + "-" + request.initialPrice + "-" + request.depositScale + "-" + request.slippageBps; + } +}