feat(apps/amm): remove liquidity from the pool detail view

The module already had removeLiquidityQuote and removeLiquidity, but
AmmUiBackend never forwarded them, so QML had no way to reach them. Expose
both, mirroring the add pair: the quote is read-only and unguarded, the
submit is wallet-guarded and refreshes balances once the withdrawal lands.
Unlike createPool/addLiquidity nothing fresh is created -- the request names
the existing LP holding to burn and the two holdings that receive.

The pool detail view's secondary action reads "Add liquidity" until the
wallet holds LP for that pool, then becomes "Manage position" with a
dropdown offering both directions, opening on hover with the same deferred
close the nav bar's tab menu uses. Remove is disabled until the LP account
and both receiving holdings resolve.

RemoveLiquidityDialog is a modal sheet: 25/50/75/Max presets over a slider,
a debounced quote of both withdrawals, the post-slippage floors, submit. The
quote is generation-tagged because dragging the slider fires quotes faster
than they return and a late reply must not paint over a newer percentage.
The submit passes the quote's own minimumAmount*Raw as its floors, so it
enforces what the preview promised. 100% burns the balance exactly; every
other percentage floors, so rounding can't push the request past it.
This commit is contained in:
r4bbit
2026-08-20 20:02:11 +02:00
parent 99ea6805df
commit 78edd23b5b
6 changed files with 839 additions and 10 deletions
+10
View File
@@ -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
@@ -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
}
}
}
+304 -10
View File
@@ -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
+27
View File
@@ -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;
}
+6
View File
@@ -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
+21
View File
@@ -146,6 +146,27 @@ class AmmUiBackend
// { status:"error", error:<code> } (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:<code> } —
// 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:<hex tx hash> } on
// success, else { status:"error", error:<code> } (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,