mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user