diff --git a/apps/amm/README.md b/apps/amm/README.md index b77eace..b248c92 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -254,6 +254,16 @@ fee tier, the wallet's claim on both reserves (`reserve × lpBalance / lpSupply` floored like the program's own payout), and its share of the pool. The list needs an open wallet. +On the pool detail view, the secondary action reads *Add liquidity* until the +wallet holds LP tokens for that pool; then it becomes *Manage position*, whose +hover dropdown offers both *Add liquidity* and *Remove liquidity*. Removing +opens a sheet with the usual percentage presets and a slider, previews the two +withdrawals through `removeLiquidityQuote`, and submits through +`removeLiquidity` with the previewed amounts as the slippage floors. Note that +every add mints into a *fresh* LP holding, so a wallet that has added twice +holds two LP accounts for one pool; a burn names a single account, so the sheet +draws on the largest and says so when the position spans more than one. + Clicking a row in the Pools list opens the pool detail view, which reads the live pool through `resolvePoolAccount` and shows the reserve split, spot price, fee tier, LP supply, an estimate of the fees accrued into the reserves, and the pool's diff --git a/apps/amm/qml/components/liquidity/RemoveLiquidityDialog.qml b/apps/amm/qml/components/liquidity/RemoveLiquidityDialog.qml new file mode 100644 index 0000000..f6afaa5 --- /dev/null +++ b/apps/amm/qml/components/liquidity/RemoveLiquidityDialog.qml @@ -0,0 +1,471 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "AmountMath.js" as AmountMath + +// Remove-liquidity sheet: pick how much of the position to withdraw, preview what +// comes back, submit. Modelled on Uniswap's remove flow — percentage presets over +// a slider, with the two token amounts previewed underneath. +// +// The caller supplies the position (lpBalance + the holdings involved); this +// component owns only the amount selection and the backend round-trips. +Popup { + id: root + + required property var theme + // Real backend replica (logos.module("amm_ui")) and the watch runtime. + property var backend: null + property var runtime: null + + // Pair display + ids, from the pool detail view. + property string symbolA: "" + property string symbolB: "" + property string tokenAId: "" + property string tokenBId: "" + // The balance of the single LP holding being burned from, and the accounts the + // submit names. A burn names one LP account, so this is the withdrawal's ceiling. + property string lpBalance: "0" + // The position's total LP across every holding. Each add mints into a fresh LP + // account, so a wallet that added twice holds two for one pool and the total can + // exceed what a single withdrawal reaches. + property string lpBalanceTotal: "0" + property string lpHoldingId: "" + readonly property bool positionIsSplit: AmountMath.isUnsigned(root.lpBalanceTotal) + && AmountMath.compare(root.lpBalanceTotal, + root.lpBalance) > 0 + property string holdingAId: "" + property string holdingBId: "" + + property int slippageBps: 50 + + // 1..100. Percent rather than a raw amount: it is what the presets and the + // slider both drive, and it keeps "all of it" exact (see lpAmountRaw). + property int percent: 50 + + // ── Quote state (backend.removeLiquidityQuote) ─────────────────────────── + property bool quoteLoading: false + property string quoteError: "" + property string amountARaw: "0" + property string amountBRaw: "0" + property string minimumAmountARaw: "0" + property string minimumAmountBRaw: "0" + property bool quoteReady: false + + property bool submitting: false + property string submitError: "" + + // Monotonic tag: the slider fires quotes faster than they return, and an + // earlier reply must not overwrite a later percentage's preview. + property int quoteGeneration: 0 + + signal removed(string transactionId) + + // 100% burns the whole balance exactly; anything else floors, so the dust + // stays in the position rather than rounding the request above the balance. + readonly property string lpAmountRaw: root.percent >= 100 + ? AmountMath.normalize(root.lpBalance) + : AmountMath.mulDivFloor(root.lpBalance, String(root.percent), "100") + + readonly property bool hasAmount: AmountMath.isUnsigned(root.lpAmountRaw) + && AmountMath.normalize(root.lpAmountRaw) !== "0" + readonly property bool canSubmit: root.hasAmount + && root.quoteReady + && !root.quoteLoading + && !root.submitting + && root.quoteError.length === 0 + && root.lpHoldingId.length > 0 + && root.holdingAId.length > 0 + && root.holdingBId.length > 0 + + // Opens the sheet on a fresh position, resetting the previous visit's state. + function openFor(position) { + root.symbolA = String(position.symbolA || "") + root.symbolB = String(position.symbolB || "") + root.tokenAId = String(position.tokenAId || "") + root.tokenBId = String(position.tokenBId || "") + root.lpBalance = String(position.lpBalance || "0") + root.lpBalanceTotal = String(position.lpBalanceTotal || position.lpBalance || "0") + root.lpHoldingId = String(position.lpHoldingId || "") + root.holdingAId = String(position.holdingAId || "") + root.holdingBId = String(position.holdingBId || "") + root.percent = 50 + root.quoteGeneration++ + quoteDebounce.stop() + root.quoteLoading = false + root.submitting = false + root.quoteError = "" + root.submitError = "" + root.quoteReady = false + root.amountARaw = "0" + root.amountBRaw = "0" + root.minimumAmountARaw = "0" + root.minimumAmountBRaw = "0" + root.open() + root.requestQuote() + } + + onPercentChanged: root.requestQuote() + onSlippageBpsChanged: root.requestQuote() + + Timer { + id: quoteDebounce + + interval: 250 + repeat: false + onTriggered: root.doQuote() + } + + function requestQuote() { + root.quoteReady = false + if (!root.opened) + return + quoteDebounce.restart() + } + + function doQuote() { + if (!root.backend || !root.runtime || !root.hasAmount) + return + + const generation = ++root.quoteGeneration + root.quoteLoading = true + root.runtime.watch(root.backend.removeLiquidityQuote({ + "tokenAId": root.tokenAId, + "tokenBId": root.tokenBId, + "lpAmountRaw": root.lpAmountRaw, + "slippageBps": root.slippageBps + }), + function(quote) { + if (generation !== root.quoteGeneration) + return + root.quoteLoading = false + if (quote && quote.status === "ok") { + root.amountARaw = String(quote.amountARaw || "0") + root.amountBRaw = String(quote.amountBRaw || "0") + root.minimumAmountARaw = String(quote.minimumAmountARaw || "0") + root.minimumAmountBRaw = String(quote.minimumAmountBRaw || "0") + root.quoteError = "" + root.quoteReady = true + return + } + root.quoteReady = false + root.quoteError = root.issueText((quote && quote.error) || "backend_error") + }, + function(error) { + if (generation !== root.quoteGeneration) + return + console.warn("removeLiquidityQuote error:", error) + root.quoteLoading = false + root.quoteReady = false + root.quoteError = root.issueText("backend_error") + }) + } + + function submit() { + if (!root.canSubmit || !root.backend || !root.runtime) + return + + root.submitting = true + root.submitError = "" + root.runtime.watch(root.backend.removeLiquidity({ + "tokenAId": root.tokenAId, + "tokenBId": root.tokenBId, + "holdingAId": root.holdingAId, + "holdingBId": root.holdingBId, + "lpHoldingId": root.lpHoldingId, + "lpAmountRaw": root.lpAmountRaw, + // The floors the quote computed for this exact amount, so the submit + // enforces the slippage the preview promised. + "minAmountARaw": root.minimumAmountARaw, + "minAmountBRaw": root.minimumAmountBRaw, + // u64-max sentinel = no deadline, same as the other submits. + "deadlineMs": "18446744073709551615" + }), + function(result) { + root.submitting = false + if (result && result.status === "ok" + && String(result.transactionId || "").length > 0) { + root.removed(String(result.transactionId)) + root.close() + return + } + root.submitError = root.issueText((result && result.error) + || "wallet_submission_failed") + }, + function(error) { + console.warn("removeLiquidity error:", error) + root.submitting = false + root.submitError = root.issueText("wallet_submission_failed") + }) + } + + function issueText(code) { + switch (String(code)) { + case "no_pool": + return qsTr("This pool no longer exists on-chain.") + case "insufficient_pool_liquidity": + return qsTr("The pool cannot release this much — some liquidity is permanently locked.") + case "amount_too_low": + return qsTr("This amount is too small to withdraw anything.") + case "minimum_amount_zero": + return qsTr("This amount rounds to nothing on one side. Withdraw more.") + case "invalid_slippage": + return qsTr("The slippage tolerance is out of range.") + case "pair_mismatch": + return qsTr("The pool does not match this token pair.") + case "wallet_unavailable": + return qsTr("Connect a wallet to withdraw.") + case "wallet_submission_failed": + return qsTr("The withdrawal could not be submitted.") + case "config_missing": + return qsTr("The AMM config account has not been initialized.") + default: + return qsTr("Withdrawal failed: %1").arg(String(code)) + } + } + + // Group an exact decimal string; the amounts are u128 base units. + function amountText(rawValue) { + var digits = String(rawValue).replace(/[^0-9]/g, "").replace(/^0+(?=[0-9])/, "") + if (digits.length === 0) + return "0" + var separator = Qt.locale().groupSeparator + var grouped = "" + for (var i = 0; i < digits.length; ++i) { + if (i > 0 && (digits.length - i) % 3 === 0) + grouped += separator + grouped += digits[i] + } + return grouped + } + + objectName: "removeLiquidityDialog" + modal: true + anchors.centerIn: Overlay.overlay + width: Math.min(420, (parent ? parent.width : 420) - 32) + padding: 20 + closePolicy: root.submitting ? Popup.NoAutoClose + : (Popup.CloseOnEscape | Popup.CloseOnPressOutside) + + background: Rectangle { + radius: 20 + color: root.theme.colors.cardBg + border.color: root.theme.colors.border + border.width: 1 + } + + Overlay.modal: Rectangle { + color: "#B0000000" + } + + contentItem: ColumnLayout { + spacing: 18 + + Text { + Layout.fillWidth: true + text: qsTr("Remove liquidity") + color: root.theme.colors.textPrimary + font.pixelSize: 20 + font.weight: Font.Bold + elide: Text.ElideRight + } + + // ── Amount picker ──────────────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + spacing: 12 + + Text { + objectName: "removePercentLabel" + Layout.fillWidth: true + text: qsTr("%1%").arg(root.percent) + color: root.theme.colors.textPrimary + font.pixelSize: 34 + font.weight: Font.Bold + horizontalAlignment: Text.AlignHCenter + } + + Slider { + id: percentSlider + + objectName: "removePercentSlider" + Layout.fillWidth: true + from: 1 + to: 100 + stepSize: 1 + value: root.percent + onMoved: root.percent = Math.round(value) + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Repeater { + model: [25, 50, 75, 100] + + delegate: Rectangle { + id: preset + + required property int modelData + + readonly property bool current: root.percent === preset.modelData + + objectName: "removePreset%1".arg(preset.modelData) + Layout.fillWidth: true + Layout.preferredHeight: 34 + radius: 8 + color: preset.current ? root.theme.colors.selection + : root.theme.colors.inputBg + border.color: preset.current ? root.theme.colors.ctaBg + : root.theme.colors.borderStrong + border.width: 1 + + Accessible.role: Accessible.Button + Accessible.name: presetLabel.text + + Text { + id: presetLabel + + anchors.centerIn: parent + text: preset.modelData === 100 + ? qsTr("Max") : qsTr("%1%").arg(preset.modelData) + color: root.theme.colors.textPrimary + font.pixelSize: 13 + font.weight: preset.current ? Font.DemiBold : Font.Normal + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.percent = preset.modelData + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: root.theme.colors.divider + } + + // ── Preview ────────────────────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + Layout.fillWidth: true + text: qsTr("You receive") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + font.weight: Font.DemiBold + } + + AmountLine { + objectName: "removeReceiveA" + symbol: root.symbolA + amount: root.quoteReady ? root.amountText(root.amountARaw) : qsTr("—") + } + + AmountLine { + objectName: "removeReceiveB" + symbol: root.symbolB + amount: root.quoteReady ? root.amountText(root.amountBRaw) : qsTr("—") + } + + Text { + Layout.fillWidth: true + visible: root.quoteReady + text: qsTr("At least %1 %2 and %3 %4 after slippage.") + .arg(root.amountText(root.minimumAmountARaw)).arg(root.symbolA) + .arg(root.amountText(root.minimumAmountBRaw)).arg(root.symbolB) + color: root.theme.colors.textPlaceholder + font.pixelSize: 11 + wrapMode: Text.Wrap + } + + Text { + objectName: "removeSplitPositionNote" + Layout.fillWidth: true + visible: root.positionIsSplit + text: qsTr("A withdrawal burns one LP account at a time. This one holds %1 of your %2 LP; repeat to withdraw the rest.") + .arg(root.amountText(root.lpBalance)) + .arg(root.amountText(root.lpBalanceTotal)) + color: root.theme.colors.textPlaceholder + font.pixelSize: 11 + wrapMode: Text.Wrap + } + + Text { + objectName: "removeDialogStatus" + Layout.fillWidth: true + visible: text.length > 0 + text: root.submitError.length > 0 ? root.submitError + : root.quoteError.length > 0 ? root.quoteError + : root.quoteLoading ? qsTr("Loading preview…") : "" + color: root.submitError.length > 0 || root.quoteError.length > 0 + ? root.theme.colors.error : root.theme.colors.textSecondary + font.pixelSize: 12 + wrapMode: Text.Wrap + } + } + + // ── Actions ────────────────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + spacing: 12 + + AmmSecondaryButton { + objectName: "removeCancelButton" + theme: root.theme + text: qsTr("Cancel") + enabled: !root.submitting + Layout.fillWidth: true + onClicked: root.close() + } + + AmmPrimaryButton { + objectName: "removeConfirmButton" + theme: root.theme + text: root.submitting ? qsTr("Removing…") : qsTr("Remove") + enabled: root.canSubmit + implicitHeight: 44 + Layout.fillWidth: true + onClicked: root.submit() + } + } + } + + component AmountLine: RowLayout { + id: line + + property string symbol: "" + property string amount: "" + + Layout.fillWidth: true + spacing: 10 + + Text { + text: line.symbol + color: root.theme.colors.textSecondary + font.pixelSize: 14 + elide: Text.ElideRight + Layout.fillWidth: true + Layout.preferredWidth: implicitWidth + } + + Text { + text: line.amount + color: root.theme.colors.textPrimary + font.pixelSize: 14 + font.weight: Font.Medium + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.fillWidth: true + Layout.preferredWidth: implicitWidth + } + } +} diff --git a/apps/amm/qml/pages/PoolDetailPage.qml b/apps/amm/qml/pages/PoolDetailPage.qml index eb23ca1..0285130 100644 --- a/apps/amm/qml/pages/PoolDetailPage.qml +++ b/apps/amm/qml/pages/PoolDetailPage.qml @@ -5,6 +5,7 @@ import QtQuick.Controls import QtQuick.Layouts import "../components/liquidity" +import "../components/shared" import "../components/liquidity/AmountMath.js" as AmountMath import "../components/shared/TokenVisuals.js" as TokenVisuals @@ -72,6 +73,27 @@ Item { ? root.resolvedPoolId : (root.pool ? String(root.pool.poolId || "") : "") + // ── Wallet position (backend.tokenHoldings) ────────────────────────────── + // Loaded alongside the pool so the actions know whether there is anything to + // withdraw. Every add mints into a fresh LP account, so a pool's position can + // span several holdings: lpBalanceTotal is the position, lpHolding* is the one + // account a withdrawal can burn from. + property var holdings: [] + property string lpBalanceTotal: "0" + property string lpHoldingId: "" + property string lpHoldingBalance: "0" + property string holdingAId: "" + property string holdingBId: "" + property int holdingsGeneration: 0 + + readonly property bool hasPosition: AmountMath.isUnsigned(root.lpBalanceTotal) + && AmountMath.normalize(root.lpBalanceTotal) !== "0" + // Withdrawing needs the LP account to burn and both receiving holdings. + readonly property bool canRemoveLiquidity: root.hasPosition + && root.lpHoldingId.length > 0 + && root.holdingAId.length > 0 + && root.holdingBId.length > 0 + readonly property bool hasDefinitionIds: root.definitionIdA.length > 0 && root.definitionIdB.length > 0 // Both actions need the pair's definition ids to preselect anything; @@ -144,9 +166,95 @@ Item { }) } - onPoolChanged: root.loadPoolState() - onBackendChanged: root.loadPoolState() - onRuntimeChanged: root.loadPoolState() + // tokenHoldings() needs an open wallet and is independent of the pool read, so + // it runs on its own and just refines the actions once it lands. + function loadHoldings() { + root.holdings = [] + root.lpBalanceTotal = "0" + root.lpHoldingId = "" + root.lpHoldingBalance = "0" + root.holdingAId = "" + root.holdingBId = "" + + if (!root.backend || !root.runtime || !root.pool || !root.backend.isWalletOpen) + return + + const generation = ++root.holdingsGeneration + root.runtime.watch(root.backend.tokenHoldings(), + function(list) { + if (generation !== root.holdingsGeneration) + return + root.holdings = list || [] + root.applyHoldings() + }, + function(err) { + if (generation !== root.holdingsGeneration) + return + console.warn("tokenHoldings error:", err) + }) + } + + // Matches a holding on whichever encoding the id uses: tokenHoldings emits a + // base58 definitionId and a hex definitionIdHex per row, while the configured + // token ids and the pool's lpDefinitionId can be either (see TokenInput). + function holdingsFor(definitionId) { + var out = [] + if (definitionId.length === 0) + return out + var isHex = /^[0-9a-fA-F]{64}$/.test(definitionId) + var field = isHex ? "definitionIdHex" : "definitionId" + var needle = isHex ? definitionId.toLowerCase() : definitionId + for (var i = 0; i < root.holdings.length; ++i) { + var holding = root.holdings[i] + var value = String(holding[field] || "") + if ((isHex ? value.toLowerCase() : value) === needle) + out.push(holding) + } + return out + } + + function applyHoldings() { + // The LP side: total across every holding is the position, but a burn names + // one account, so the largest is the one a withdrawal can draw on. + var lpHoldings = root.holdingsFor(root.lpDefinitionId) + var total = "0" + var best = null + for (var i = 0; i < lpHoldings.length; ++i) { + var balance = String(lpHoldings[i].balanceRaw || "0") + if (!AmountMath.isUnsigned(balance)) + continue + total = AmountMath.add(total, balance) + if (!best || AmountMath.compare(balance, String(best.balanceRaw || "0")) > 0) + best = lpHoldings[i] + } + root.lpBalanceTotal = total + root.lpHoldingId = best ? String(best.accountId || "") : "" + root.lpHoldingBalance = best ? String(best.balanceRaw || "0") : "0" + + // The receiving side: any existing holding for each token will do. + var holdingsA = root.holdingsFor(root.definitionIdA) + var holdingsB = root.holdingsFor(root.definitionIdB) + root.holdingAId = holdingsA.length > 0 ? String(holdingsA[0].accountId || "") : "" + root.holdingBId = holdingsB.length > 0 ? String(holdingsB[0].accountId || "") : "" + } + + function refresh() { + root.loadPoolState() + root.loadHoldings() + } + + onPoolChanged: root.refresh() + onBackendChanged: root.refresh() + onRuntimeChanged: root.refresh() + + // The LP definition only arrives with the pool read, which can land after the + // holdings; re-match when it does. + onLpDefinitionIdChanged: root.applyHoldings() + + Connections { + target: root.backend + function onIsWalletOpenChanged() { root.loadHoldings() } + } function issueText(code) { switch (String(code)) { @@ -259,6 +367,20 @@ Item { return text.length > 0 ? text : qsTr("—") } + function openRemoveDialog() { + removeDialog.openFor({ + "symbolA": root.symbolA, + "symbolB": root.symbolB, + "tokenAId": root.definitionIdA, + "tokenBId": root.definitionIdB, + "lpBalance": root.lpHoldingBalance, + "lpBalanceTotal": root.lpBalanceTotal, + "lpHoldingId": root.lpHoldingId, + "holdingAId": root.holdingAId, + "holdingBId": root.holdingBId + }) + } + AmmTheme { id: theme } @@ -268,6 +390,34 @@ Item { color: theme.colors.background } + RemoveLiquidityDialog { + id: removeDialog + + theme: theme + backend: root.backend + runtime: root.runtime + + // The withdrawal moved the reserves and the wallet's LP, so both reads this + // page shows are stale the moment it lands. + onRemoved: function(transactionId) { + removeToast.show(qsTr("Liquidity removed"), transactionId) + root.refresh() + } + } + + SuccessToast { + id: removeToast + + objectName: "poolDetailRemoveToast" + width: Math.max(0, Math.min(380, parent.width - 32)) + + anchors { + bottom: parent.bottom + bottomMargin: 24 + horizontalCenter: parent.horizontalCenter + } + } + Flickable { id: scroll @@ -433,14 +583,119 @@ Item { onClicked: root.swapRequested(root.pool) } - AmmSecondaryButton { - objectName: "poolDetailAddLiquidityButton" - theme: theme - text: qsTr("Add liquidity") - enabled: root.canAddLiquidity + // Plain "Add liquidity" until the wallet actually holds LP + // here; then it becomes a manage menu offering both directions. + Item { + id: manageAction + Layout.fillWidth: !root.wideLayout - Layout.preferredWidth: root.wideLayout ? 150 : -1 - onClicked: root.addLiquidityRequested(root.pool) + Layout.preferredWidth: root.wideLayout + ? (root.hasPosition ? 170 : 150) : -1 + Layout.preferredHeight: 44 + + // Same hover bookkeeping as the nav bar's tab menu: the + // pointer is over neither while it crosses the gap. + property bool pointerOnButton: false + property bool pointerInMenu: false + + function openMenu() { + if (!root.hasPosition) + return + manageMenuCloseTimer.stop() + manageMenu.open() + } + + function scheduleMenuClose() { + manageMenuCloseTimer.restart() + } + + Timer { + id: manageMenuCloseTimer + + interval: 180 + onTriggered: { + if (!manageAction.pointerOnButton && !manageAction.pointerInMenu) + manageMenu.close() + } + } + + AmmSecondaryButton { + id: manageButton + + objectName: "poolDetailAddLiquidityButton" + anchors.fill: parent + theme: theme + text: root.hasPosition ? qsTr("Manage position") + : qsTr("Add liquidity") + enabled: root.canAddLiquidity + + onHoveredChanged: { + manageAction.pointerOnButton = hovered + if (hovered) + manageAction.openMenu() + else + manageAction.scheduleMenuClose() + } + + onClicked: { + if (root.hasPosition) + manageAction.openMenu() + else + root.addLiquidityRequested(root.pool) + } + } + + Popup { + id: manageMenu + + objectName: "poolDetailManageMenu" + y: manageAction.height + 6 + width: 190 + padding: 6 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + radius: 12 + color: theme.colors.cardBg + border.color: theme.colors.borderStrong + border.width: 1 + } + + contentItem: Column { + spacing: 2 + + HoverHandler { + onHoveredChanged: { + manageAction.pointerInMenu = hovered + if (hovered) + manageMenuCloseTimer.stop() + else + manageAction.scheduleMenuClose() + } + } + + ManageEntry { + objectName: "poolDetailManageAdd" + width: manageMenu.availableWidth + text: qsTr("Add liquidity") + onActivated: { + manageMenu.close() + root.addLiquidityRequested(root.pool) + } + } + + ManageEntry { + objectName: "poolDetailManageRemove" + width: manageMenu.availableWidth + text: qsTr("Remove liquidity") + enabled: root.canRemoveLiquidity + onActivated: { + manageMenu.close() + root.openRemoveDialog() + } + } + } + } } } } @@ -723,6 +978,45 @@ Item { // Anchors rather than a RowLayout: the value is capped against the row's own // width, and a layout child whose maximumWidth depends on the row width can // feed back into the row's implicit size. + component ManageEntry: Rectangle { + id: entry + + property string text: "" + signal activated() + + height: 36 + radius: 8 + color: entryMouse.containsMouse && entry.enabled + ? theme.colors.panelHoverBg : "transparent" + + Accessible.role: Accessible.MenuItem + Accessible.name: entry.text + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: entry.text + color: entry.enabled ? theme.colors.textPrimary : theme.colors.textPlaceholder + font.pixelSize: 14 + elide: Text.ElideRight + } + + MouseArea { + id: entryMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: entry.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (entry.enabled) + entry.activated() + } + } + } + component StatRow: Item { id: statRow diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index efe6227..1f9447c 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -348,6 +348,13 @@ QVariantMap AmmUiBackend::addLiquidityQuote(QVariantMap request) return m_logos->amm_module.addLiquidityQuote(request); } +QVariantMap AmmUiBackend::removeLiquidityQuote(QVariantMap request) +{ + // Read-only remove-liquidity preview — no wallet guard, mirroring the add quote. + // The module reads the pool and runs the guest's floor(reserve*lp/supply) math. + return m_logos->amm_module.removeLiquidityQuote(request); +} + QVariantList AmmUiBackend::tokenHoldings() { // Read-only list of the wallet's token holdings for the account selector. Gated @@ -555,3 +562,23 @@ QVariantMap AmmUiBackend::addLiquidity(QVariantMap request) refreshBalances(); return result; } + +QVariantMap AmmUiBackend::removeLiquidity(QVariantMap request) +{ + // Same connected-state submit guard as createPool/addLiquidity — this app's lock is + // authoritative even though the shared wallet may remain open elsewhere. + if (!isWalletOpen()) + return QVariantMap { + { QStringLiteral("status"), QStringLiteral("error") }, + { QStringLiteral("error"), QStringLiteral("wallet_unavailable") }, + }; + + // Every account involved already exists (the LP holding is burned from, the token + // holdings receive), so unlike the add path there is nothing to create first — + // forward and refresh balances once the withdrawal lands. + const QVariantMap result = m_logos->amm_module.removeLiquidity(request); + if (result.value(QStringLiteral("status")).toString() == QStringLiteral("ok") + && !result.value(QStringLiteral("transactionId")).toString().isEmpty()) + refreshBalances(); + return result; +} diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 617c381..fff1fd9 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -83,6 +83,12 @@ public slots: // Add-liquidity submit. Forwards to the module; the flow supplies a fresh LP // holding in the request (the backend creates no wallet accounts here). QVariantMap addLiquidity(QVariantMap request) override; + // Read-only remove-liquidity preview (forwards to the module). + QVariantMap removeLiquidityQuote(QVariantMap request) override; + // Remove-liquidity submit. Forwards to the module; unlike create/add nothing fresh + // is created — the request names the existing LP holding to burn from and the two + // token holdings that receive the withdrawal. + QVariantMap removeLiquidity(QVariantMap request) override; // Lists the wallet's fungible token holdings for the account selector. QVariantList tokenHoldings() override; // Reads the known-pools list from AMM_POOLS_CONFIG (app config JSON, read diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index b90ea2d..cc9bd42 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -146,6 +146,27 @@ class AmmUiBackend // { status:"error", error: } (wallet_unavailable, config_missing, // invalid_account_id, bad_amount, no_pool, wallet_submission_failed, backend_error). SLOT(QVariantMap addLiquidity(QVariantMap request)) + // Server-side remove-liquidity preview: burning `lpAmountRaw` returns the + // proportional share of each reserve. `request` carries { tokenAId, tokenBId, + // lpAmountRaw, slippageBps } (ids hex or base58). Reads the pool and returns + // { status:"ok", error:"", amountARaw, amountBRaw (the withdrawals, display + // order), minimumAmountARaw, minimumAmountBRaw (the slippage floors the submit + // enforces), priceRaw }. On failure { status:"error", error: } — + // no_pool, pair_mismatch, invalid_token_id, invalid_slippage, + // insufficient_pool_liquidity, amount_too_low, minimum_amount_zero, + // bad_amount, config_missing, backend_error. Read-only, no submission. + SLOT(QVariantMap removeLiquidityQuote(QVariantMap request)) + // Submits a RemoveLiquidity transaction against the request's pool. `request` + // carries { tokenAId, tokenBId, holdingAId, holdingBId, lpHoldingId, lpAmountRaw, + // minAmountARaw, minAmountBRaw, deadlineMs } (ids hex or base58; + // amounts/deadline decimal strings). Unlike createPool/addLiquidity nothing + // fresh is created: lpHoldingId is the existing holding burned from, and the + // token a/b holdings receive the withdrawal. minAmount*Raw are the slippage + // floors. Returns { status:"ok", error:"", transactionId: } on + // success, else { status:"error", error: } (wallet_unavailable, + // config_missing, invalid_account_id, bad_amount, no_pool, + // wallet_submission_failed, backend_error). + SLOT(QVariantMap removeLiquidity(QVariantMap request)) // Lists the connected wallet's fungible token holdings for the account // selector: [{ accountId, accountType:"TokenHolding", definitionId, // definitionIdHex, balanceRaw }] — one row per holding account, every token,