mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(apps/amm): introduce positions view
This view allows the user to manage their positions.
This commit is contained in:
+12
-1
@@ -243,7 +243,18 @@ list shows its empty state. Entries missing `tokenA`, `tokenB`, or a numeric
|
||||
`feeBps` are skipped individually. The AMM testnet setup script writes this file
|
||||
for the pool(s) it seeds (see below).
|
||||
|
||||
Clicking a row opens the pool detail view, which reads the live pool through
|
||||
The **Pool** tab in the nav bar is a dropdown with two entries. *Create pool*
|
||||
opens the new-position / add-liquidity form. *View positions* lists the wallet's
|
||||
liquidity positions: the program has no "list my positions" read and an LP
|
||||
definition id cannot be reversed back to its pool, so the app resolves every
|
||||
pool in this config and matches each pool's `lpDefinitionId` against the
|
||||
wallet's token holdings. A pool that is not in the config therefore cannot
|
||||
appear, however many LP tokens the wallet holds for it. Each row shows the pair,
|
||||
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.
|
||||
|
||||
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
|
||||
account ids. Its **Swap** and **Add liquidity** buttons switch tabs with the
|
||||
|
||||
+44
-14
@@ -20,17 +20,24 @@ Item {
|
||||
// StackView here — the Pools tab renders either the list or the detail view.
|
||||
property var selectedPool: null
|
||||
|
||||
// Open a pool's detail view on the Explore tab. select() clears selectedPool
|
||||
// via onTabChanged, so the pool is assigned after it, not before.
|
||||
function openPoolDetail(pool) {
|
||||
navbar.select(1, 0)
|
||||
root.selectedPool = pool
|
||||
}
|
||||
|
||||
// Hand a pool's pair to the Trade tab and leave the detail view.
|
||||
function openSwapFor(pool) {
|
||||
root.selectedPool = null
|
||||
navbar.currentIndex = 0
|
||||
navbar.select(0, 0)
|
||||
swapPage.selectPair(pool)
|
||||
}
|
||||
|
||||
// Hand a pool's pair to the Liquidity tab and leave the detail view.
|
||||
// Hand a pool's pair to the Pool tab's create/add form and leave the detail view.
|
||||
function openLiquidityFor(pool) {
|
||||
root.selectedPool = null
|
||||
navbar.currentIndex = 1
|
||||
navbar.select(2, 1)
|
||||
liquidityPage.selectPair(pool)
|
||||
}
|
||||
|
||||
@@ -84,6 +91,7 @@ Item {
|
||||
// control. Trade/Liquidity render immediately on launch.
|
||||
NavBar {
|
||||
id: navbar
|
||||
objectName: "navBar"
|
||||
anchors.top: connectionBanner.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
@@ -91,6 +99,16 @@ Item {
|
||||
|
||||
backend: root.ready ? root.backend : null
|
||||
accountModel: root.accountModel
|
||||
|
||||
// Any nav selection leaves the pool detail view behind, so returning to
|
||||
// Explore lands on the list rather than on the pool opened last time.
|
||||
// Entering the create-pool form clears it for the same reason; a pair
|
||||
// handed over by openLiquidityFor() is applied after this runs.
|
||||
onTabChanged: {
|
||||
root.selectedPool = null
|
||||
if (navbar.currentIndex === 2 && navbar.currentSubIndex === 1)
|
||||
liquidityPage.resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -107,20 +125,12 @@ Item {
|
||||
backend: root.ready ? root.backend : null
|
||||
}
|
||||
|
||||
LiquidityPage {
|
||||
id: liquidityPage
|
||||
|
||||
anchors.fill: parent
|
||||
backend: root.ready ? root.backend : null
|
||||
runtime: logos
|
||||
visible: navbar.currentIndex === 1
|
||||
}
|
||||
|
||||
// Explore tab: the pool list, or the detail view for the pool opened from it.
|
||||
PoolsPage {
|
||||
anchors.fill: parent
|
||||
backend: root.ready ? root.backend : null
|
||||
runtime: logos
|
||||
visible: navbar.currentIndex === 2 && root.selectedPool === null
|
||||
visible: navbar.currentIndex === 1 && root.selectedPool === null
|
||||
|
||||
onPoolActivated: function(pool) { root.selectedPool = pool }
|
||||
}
|
||||
@@ -129,12 +139,32 @@ Item {
|
||||
anchors.fill: parent
|
||||
backend: root.ready ? root.backend : null
|
||||
runtime: logos
|
||||
visible: navbar.currentIndex === 2 && root.selectedPool !== null
|
||||
visible: navbar.currentIndex === 1 && root.selectedPool !== null
|
||||
pool: root.selectedPool
|
||||
|
||||
onBackRequested: root.selectedPool = null
|
||||
onSwapRequested: function(pool) { root.openSwapFor(pool) }
|
||||
onAddLiquidityRequested: function(pool) { root.openLiquidityFor(pool) }
|
||||
}
|
||||
|
||||
// Pool tab, "View positions": the wallet's LP holdings matched to pools.
|
||||
PositionsPage {
|
||||
anchors.fill: parent
|
||||
backend: root.ready ? root.backend : null
|
||||
runtime: logos
|
||||
visible: navbar.currentIndex === 2 && navbar.currentSubIndex === 0
|
||||
|
||||
onPositionActivated: function(position) { root.openPoolDetail(position) }
|
||||
}
|
||||
|
||||
// Pool tab, "Create pool": the new-position / add-liquidity form.
|
||||
LiquidityPage {
|
||||
id: liquidityPage
|
||||
|
||||
anchors.fill: parent
|
||||
backend: root.ready ? root.backend : null
|
||||
runtime: logos
|
||||
visible: navbar.currentIndex === 2 && navbar.currentSubIndex === 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
|
||||
import Logos.Theme
|
||||
@@ -8,11 +9,19 @@ import Logos.Wallet
|
||||
|
||||
// Self-contained navigation bar — styling is independent of any view's theme.
|
||||
// Use currentIndex to read the active tab; tabChanged(index) fires on selection.
|
||||
// A tab carrying `items` opens a dropdown instead of switching directly, and the
|
||||
// chosen entry lands in currentSubIndex (always 0 for tabs without a menu).
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int currentIndex: 0
|
||||
readonly property var tabs: [qsTr("Trade"), qsTr("Liquidity"), qsTr("Pools")]
|
||||
property int currentSubIndex: 0
|
||||
|
||||
readonly property var tabs: [
|
||||
{ "label": qsTr("Trade"), "items": [] },
|
||||
{ "label": qsTr("Explore"), "items": [] },
|
||||
{ "label": qsTr("Pool"), "items": [qsTr("View positions"), qsTr("Create pool")] }
|
||||
]
|
||||
|
||||
// Wallet wiring, passed down from Main.qml.
|
||||
property var backend: null
|
||||
@@ -23,6 +32,12 @@ Item {
|
||||
|
||||
signal tabChanged(int index)
|
||||
|
||||
function select(index, subIndex) {
|
||||
root.currentIndex = index
|
||||
root.currentSubIndex = subIndex
|
||||
root.tabChanged(index)
|
||||
}
|
||||
|
||||
implicitHeight: 56
|
||||
|
||||
Rectangle {
|
||||
@@ -68,9 +83,16 @@ Item {
|
||||
id: tabButton
|
||||
|
||||
required property int index
|
||||
required property string modelData
|
||||
required property var modelData
|
||||
|
||||
readonly property bool active: root.currentIndex === index
|
||||
readonly property var items: modelData.items || []
|
||||
readonly property bool hasMenu: tabButton.items.length > 0
|
||||
|
||||
// Tracks where the pointer is so the menu can stay open
|
||||
// while it travels from the tab down into the menu.
|
||||
property bool pointerOnTab: false
|
||||
property bool pointerInMenu: false
|
||||
|
||||
height: 36
|
||||
width: tabLabel.implicitWidth + 28
|
||||
@@ -82,9 +104,125 @@ Item {
|
||||
Accessible.role: Accessible.PageTab
|
||||
Accessible.name: tabLabel.text
|
||||
|
||||
// A tab with a menu defers the switch to the chosen entry;
|
||||
// one without goes straight to its page.
|
||||
function activate() {
|
||||
root.currentIndex = index
|
||||
root.tabChanged(index)
|
||||
if (tabButton.hasMenu) {
|
||||
tabButton.openMenu()
|
||||
return
|
||||
}
|
||||
root.select(tabButton.index, 0)
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
if (!tabButton.hasMenu)
|
||||
return
|
||||
menuCloseTimer.stop()
|
||||
tabMenu.open()
|
||||
}
|
||||
|
||||
// Closing is deferred: crossing the gap between the tab and
|
||||
// the menu leaves the pointer over neither for a moment, and
|
||||
// closing on that would make the menu unreachable by mouse.
|
||||
function scheduleMenuClose() {
|
||||
if (tabButton.hasMenu)
|
||||
menuCloseTimer.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: menuCloseTimer
|
||||
|
||||
interval: 180
|
||||
onTriggered: {
|
||||
if (!tabButton.pointerOnTab && !tabButton.pointerInMenu)
|
||||
tabMenu.close()
|
||||
}
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: tabMenu
|
||||
|
||||
objectName: "navTabMenu%1".arg(tabButton.index)
|
||||
y: tabButton.height + 6
|
||||
width: 190
|
||||
padding: 6
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
radius: 12
|
||||
color: Theme.palette.background
|
||||
border.color: Theme.palette.borderSecondary
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
contentItem: Column {
|
||||
spacing: 2
|
||||
|
||||
// A handler rather than a MouseArea: the entries
|
||||
// below have their own hoverEnabled MouseAreas,
|
||||
// which would steal a parent MouseArea's hover.
|
||||
HoverHandler {
|
||||
onHoveredChanged: {
|
||||
tabButton.pointerInMenu = hovered
|
||||
if (hovered)
|
||||
menuCloseTimer.stop()
|
||||
else
|
||||
tabButton.scheduleMenuClose()
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: tabButton.items
|
||||
|
||||
delegate: Rectangle {
|
||||
id: menuEntry
|
||||
|
||||
required property int index
|
||||
required property string modelData
|
||||
|
||||
readonly property bool current: root.currentIndex === tabButton.index
|
||||
&& root.currentSubIndex === menuEntry.index
|
||||
|
||||
objectName: "navMenuItem%1_%2".arg(tabButton.index).arg(menuEntry.index)
|
||||
width: tabMenu.availableWidth
|
||||
height: 36
|
||||
radius: 8
|
||||
color: entryMouse.containsMouse
|
||||
? Theme.palette.backgroundSecondary : "transparent"
|
||||
|
||||
Accessible.role: Accessible.MenuItem
|
||||
Accessible.name: menuEntry.modelData
|
||||
|
||||
function activate() {
|
||||
tabMenu.close()
|
||||
root.select(tabButton.index, menuEntry.index)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: menuEntry.modelData
|
||||
color: menuEntry.current || entryMouse.containsMouse
|
||||
? Theme.palette.text : Theme.palette.textSecondary
|
||||
font.pixelSize: 14
|
||||
font.weight: menuEntry.current ? Font.Medium : Font.Normal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: entryMouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: menuEntry.activate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
@@ -107,7 +245,7 @@ Item {
|
||||
Text {
|
||||
id: tabLabel
|
||||
anchors.centerIn: parent
|
||||
text: tabButton.modelData
|
||||
text: tabButton.modelData.label
|
||||
color: tabButton.active ? Theme.palette.text : Theme.palette.textSecondary
|
||||
font.pixelSize: 14
|
||||
font.weight: tabButton.active ? Font.Medium : Font.Normal
|
||||
@@ -117,7 +255,19 @@ Item {
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onEntered: {
|
||||
tabButton.pointerOnTab = true
|
||||
tabButton.openMenu()
|
||||
}
|
||||
|
||||
onExited: {
|
||||
tabButton.pointerOnTab = false
|
||||
tabButton.scheduleMenuClose()
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
tabButton.forceActiveFocus()
|
||||
tabButton.activate()
|
||||
|
||||
@@ -71,6 +71,23 @@ function subtract(left, right) {
|
||||
return normalize(result.join(""))
|
||||
}
|
||||
|
||||
function add(left, right) {
|
||||
var a = normalize(left)
|
||||
var b = normalize(right)
|
||||
var result = []
|
||||
var carry = 0
|
||||
var j = b.length - 1
|
||||
for (var i = a.length - 1; i >= 0 || j >= 0 || carry > 0; --i) {
|
||||
var digit = carry
|
||||
+ (i >= 0 ? Number(a.charAt(i)) : 0)
|
||||
+ (j >= 0 ? Number(b.charAt(j)) : 0)
|
||||
carry = digit >= 10 ? 1 : 0
|
||||
result.unshift(String(digit % 10))
|
||||
--j
|
||||
}
|
||||
return normalize(result.join(""))
|
||||
}
|
||||
|
||||
function multiply(left, right) {
|
||||
var a = normalize(left)
|
||||
var b = normalize(right)
|
||||
|
||||
@@ -801,6 +801,23 @@ AmmActionCard {
|
||||
root.requestQuote(true)
|
||||
}
|
||||
|
||||
// Back to a first-visit form. resetPairDraft() clears only the draft and keeps
|
||||
// the selected pair, which is right when the pair itself just changed; this
|
||||
// also drops the pair, the fee tier, slippage and any token-resolution error,
|
||||
// for when the form is re-entered rather than edited.
|
||||
function resetAll() {
|
||||
root.selectedTokenAId = ""
|
||||
root.selectedTokenBId = ""
|
||||
root.selectedFeeBps = 30
|
||||
root.slippageBps = 50
|
||||
root.resolvingTokenId = ""
|
||||
root.resolvingTokenSide = ""
|
||||
root.tokenResolutionError = ""
|
||||
root.tokenResolutionErrorSide = ""
|
||||
root.tokenResolutionMessage = ""
|
||||
root.resetPairDraft()
|
||||
}
|
||||
|
||||
function resetPairDraft() {
|
||||
root.activePoolQuote = ({})
|
||||
root.amountA = ""
|
||||
|
||||
@@ -210,8 +210,12 @@ Popup {
|
||||
|
||||
delegate: Item {
|
||||
id: tokenRow
|
||||
objectName: "tokenListItem"
|
||||
|
||||
required property var modelData
|
||||
// Exposed for UI tests to read the row's token (see swap.mjs).
|
||||
readonly property string tokenSymbol: root.tokenSymbol(modelData)
|
||||
|| root.tokenName(modelData)
|
||||
readonly property bool selectable: root.isSelectable(modelData)
|
||||
readonly property string disabledReason: root.disabledReasonForCode(
|
||||
modelData.code
|
||||
|
||||
@@ -39,6 +39,14 @@ Item {
|
||||
// has answered, since the handoff can arrive before the selector's rows do.
|
||||
property var pendingPair: null
|
||||
|
||||
// Clears the form back to a first-visit state. Called by Main.qml whenever the
|
||||
// page is navigated to, so a previous visit's pair and amounts don't linger.
|
||||
// A pair handed over by selectPair() is applied after this, not before.
|
||||
function resetForm() {
|
||||
root.pendingPair = null
|
||||
form.resetAll()
|
||||
}
|
||||
|
||||
// Preselects a pool's pair on the new-position form. Called by Main.qml when
|
||||
// the pool detail view's "Add liquidity" button is pressed.
|
||||
function selectPair(pool) {
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "../components/liquidity"
|
||||
import "../components/liquidity/AmountMath.js" as AmountMath
|
||||
import "../components/shared/TokenVisuals.js" as TokenVisuals
|
||||
|
||||
// The wallet's liquidity positions. The program has no "list my positions" read
|
||||
// and an LP definition id can't be reversed back to its pool, so positions are
|
||||
// discovered the other way round: resolve every pool in AMM_POOLS_CONFIG, then
|
||||
// match each pool's lpDefinitionId against the wallet's token holdings. A pool
|
||||
// the config doesn't list therefore can't surface here, however many LP tokens
|
||||
// the wallet holds for it.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
objectName: "positionsPage"
|
||||
|
||||
// Real backend replica (logos.module("amm_ui")) and the watch runtime,
|
||||
// wired from Main.qml. Null until the app is ready.
|
||||
property var backend: null
|
||||
property var runtime: null
|
||||
|
||||
readonly property int pageMargin: width < 640 ? 16 : 24
|
||||
readonly property int contentMaxWidth: 760
|
||||
|
||||
// One entry per pool the wallet holds LP tokens for; see buildPosition().
|
||||
property var positions: []
|
||||
readonly property int positionCount: root.positions ? root.positions.length : 0
|
||||
|
||||
property bool loading: false
|
||||
property string loadError: ""
|
||||
|
||||
// Emitted when a row is activated (click or keyboard); Main.qml opens the
|
||||
// pool detail view, from where the pair can be topped up.
|
||||
signal positionActivated(var position)
|
||||
|
||||
readonly property bool walletOpen: !!(root.backend && root.backend.isWalletOpen)
|
||||
readonly property bool showEmptyState: !root.loading && root.positionCount === 0
|
||||
|
||||
// Monotonic tag for a whole reload: a wallet switch mid-flight would
|
||||
// otherwise let the previous wallet's pool resolutions land in the list.
|
||||
property int generation: 0
|
||||
// Outstanding resolvePoolAccount calls for the current generation.
|
||||
property int pendingResolves: 0
|
||||
|
||||
function reload() {
|
||||
if (!root.backend || !root.runtime)
|
||||
return
|
||||
|
||||
// Reserves move with every swap, so what was resolved last time is stale
|
||||
// by the time the page is opened again. Resolving every pool for a page
|
||||
// nobody is looking at would be wasted work, so an offscreen reload just
|
||||
// marks the list dirty and onVisibleChanged refetches on the way in.
|
||||
if (!root.visible)
|
||||
return
|
||||
|
||||
const gen = ++root.generation
|
||||
root.loading = true
|
||||
root.loadError = ""
|
||||
root.positions = []
|
||||
root.pendingResolves = 0
|
||||
|
||||
// Read isWalletOpen off the backend rather than through the walletOpen
|
||||
// binding: this runs from onBackendChanged, where that binding may not
|
||||
// have re-evaluated against the new backend yet.
|
||||
if (!root.backend.isWalletOpen) {
|
||||
// tokenHoldings() needs an open wallet; without one there is nothing
|
||||
// to match pools against.
|
||||
root.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
root.runtime.watch(root.backend.poolList(),
|
||||
function(pools) {
|
||||
if (gen !== root.generation)
|
||||
return
|
||||
root.loadHoldings(gen, pools || [])
|
||||
},
|
||||
function(err) { root.failLoad(gen, "poolList", err) })
|
||||
}
|
||||
|
||||
function loadHoldings(gen, pools) {
|
||||
root.runtime.watch(root.backend.tokenHoldings(),
|
||||
function(holdings) {
|
||||
if (gen !== root.generation)
|
||||
return
|
||||
root.resolvePools(gen, pools, holdings || [])
|
||||
},
|
||||
function(err) { root.failLoad(gen, "tokenHoldings", err) })
|
||||
}
|
||||
|
||||
// Fans out one resolvePoolAccount per configured pool and collects whichever
|
||||
// come back with an LP balance. Order follows completion, not config order.
|
||||
function resolvePools(gen, pools, holdings) {
|
||||
if (pools.length === 0) {
|
||||
root.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
var collected = []
|
||||
root.pendingResolves = pools.length
|
||||
for (var i = 0; i < pools.length; ++i)
|
||||
root.resolveOne(gen, pools[i], holdings, collected)
|
||||
}
|
||||
|
||||
function resolveOne(gen, pool, holdings, collected) {
|
||||
const idA = String(pool.tokenADefinitionId || "")
|
||||
const idB = String(pool.tokenBDefinitionId || "")
|
||||
if (idA.length === 0 || idB.length === 0) {
|
||||
// A config entry without definition ids can't be resolved; it simply
|
||||
// contributes no position rather than failing the whole list.
|
||||
root.finishOne(gen, collected)
|
||||
return
|
||||
}
|
||||
|
||||
root.runtime.watch(root.backend.resolvePoolAccount(idA, idB),
|
||||
function(result) {
|
||||
if (gen !== root.generation)
|
||||
return
|
||||
var position = root.buildPosition(pool, result, holdings)
|
||||
if (position)
|
||||
collected.push(position)
|
||||
root.finishOne(gen, collected)
|
||||
},
|
||||
function(err) {
|
||||
if (gen !== root.generation)
|
||||
return
|
||||
console.warn("resolvePoolAccount error:", err)
|
||||
root.finishOne(gen, collected)
|
||||
})
|
||||
}
|
||||
|
||||
function finishOne(gen, collected) {
|
||||
if (gen !== root.generation)
|
||||
return
|
||||
root.pendingResolves -= 1
|
||||
if (root.pendingResolves > 0)
|
||||
return
|
||||
root.positions = collected
|
||||
root.loading = false
|
||||
}
|
||||
|
||||
function failLoad(gen, op, err) {
|
||||
if (gen !== root.generation)
|
||||
return
|
||||
console.warn(op + " error:", err)
|
||||
root.loading = false
|
||||
root.loadError = qsTr("Failed to load positions: %1").arg(err)
|
||||
}
|
||||
|
||||
// Total LP balance the wallet holds for one definition. Summed rather than
|
||||
// first-match: nothing stops a wallet holding the same LP token in more than
|
||||
// one account, and under-reporting a position would be worse than slow.
|
||||
function lpBalanceFor(lpDefinitionId, holdings) {
|
||||
var total = "0"
|
||||
for (var i = 0; i < holdings.length; ++i) {
|
||||
var holding = holdings[i]
|
||||
if (String(holding.definitionId || "") === lpDefinitionId)
|
||||
total = AmountMath.add(total, String(holding.balanceRaw || "0"))
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function buildPosition(pool, result, holdings) {
|
||||
if (!result || result.status !== "ok")
|
||||
return null
|
||||
|
||||
const lpDefinitionId = String(result.lpDefinitionId || "")
|
||||
if (lpDefinitionId.length === 0)
|
||||
return null
|
||||
|
||||
const supply = String(result.liquiditySupply || "0")
|
||||
if (!AmountMath.isUnsigned(supply) || AmountMath.normalize(supply) === "0")
|
||||
return null
|
||||
|
||||
const balance = root.lpBalanceFor(lpDefinitionId, holdings)
|
||||
if (AmountMath.normalize(balance) === "0")
|
||||
return null
|
||||
|
||||
const reserveA = String(result.reserveA || "0")
|
||||
const reserveB = String(result.reserveB || "0")
|
||||
|
||||
return {
|
||||
// Passed through so a row carries everything the pool detail view
|
||||
// would need, without re-reading the config.
|
||||
"tokenA": String(pool.tokenA || ""),
|
||||
"tokenB": String(pool.tokenB || ""),
|
||||
"tokenADefinitionId": String(pool.tokenADefinitionId || ""),
|
||||
"tokenBDefinitionId": String(pool.tokenBDefinitionId || ""),
|
||||
"poolId": String(result.poolId || pool.poolId || ""),
|
||||
"feeBps": result.feeBps !== undefined ? result.feeBps : (Number(pool.feeBps) || 0),
|
||||
"lpDefinitionId": lpDefinitionId,
|
||||
"lpBalance": balance,
|
||||
"liquiditySupply": supply,
|
||||
// The wallet's claim on each reserve, floored the same way the
|
||||
// program's remove_liquidity floors its payout.
|
||||
"amountA": AmountMath.mulDivFloor(reserveA, balance, supply),
|
||||
"amountB": AmountMath.mulDivFloor(reserveB, balance, supply),
|
||||
"share": (Number(balance) || 0) / (Number(supply) || 1)
|
||||
}
|
||||
}
|
||||
|
||||
onBackendChanged: root.reload()
|
||||
onRuntimeChanged: root.reload()
|
||||
// Every entry refetches: a swap on another tab moves the reserves this page's
|
||||
// amounts are derived from, and nothing else tells it that happened.
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
root.reload()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.backend
|
||||
function onIsWalletOpenChanged() { root.reload() }
|
||||
}
|
||||
|
||||
AmmTheme {
|
||||
id: theme
|
||||
}
|
||||
|
||||
function feeLabel(feeBps) {
|
||||
var percentage = Number(feeBps) / 100
|
||||
return qsTr("%1%").arg(percentage.toLocaleString(Qt.locale(), "f", 2))
|
||||
}
|
||||
|
||||
// Group an exact decimal string without routing it through Number(), which
|
||||
// would round any balance past ~15 digits.
|
||||
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
|
||||
}
|
||||
|
||||
// A dust position is still a position: report it as "<0.01%" rather than
|
||||
// rounding it to a flat 0% that reads as "you hold nothing".
|
||||
function shareText(share) {
|
||||
var percent = share * 100
|
||||
if (percent > 0 && percent < 0.01)
|
||||
return qsTr("<0.01%")
|
||||
return qsTr("%1%").arg(percent.toLocaleString(Qt.locale(), "f", 2))
|
||||
}
|
||||
|
||||
function emptyStateText() {
|
||||
if (root.loadError.length > 0)
|
||||
return root.loadError
|
||||
if (!root.walletOpen)
|
||||
return qsTr("Connect a wallet to see your liquidity positions.")
|
||||
return qsTr("No liquidity positions yet. Create a pool or add liquidity to an existing one.")
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: theme.colors.background
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: scroll
|
||||
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: Math.max(height, pageContent.y + pageContent.implicitHeight
|
||||
+ root.pageMargin)
|
||||
flickableDirection: Flickable.VerticalFlick
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
|
||||
Item {
|
||||
id: pageContent
|
||||
|
||||
x: Math.max(root.pageMargin, (scroll.width - width) / 2)
|
||||
y: root.width < 640 ? 24 : 40
|
||||
width: Math.max(0, Math.min(root.contentMaxWidth,
|
||||
scroll.width - root.pageMargin * 2))
|
||||
implicitHeight: contentColumn.implicitHeight
|
||||
|
||||
Column {
|
||||
id: contentColumn
|
||||
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: qsTr("Positions")
|
||||
color: theme.colors.textPrimary
|
||||
font.pixelSize: 30
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: qsTr("Your share of each pool, from the LP tokens in your wallet.")
|
||||
color: theme.colors.textSecondary
|
||||
font.pixelSize: 13
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: positionsList
|
||||
|
||||
objectName: "positionsList"
|
||||
width: parent.width
|
||||
implicitHeight: !root.showEmptyState && !root.loading
|
||||
? listContent.implicitHeight : 144
|
||||
color: theme.colors.cardBg
|
||||
radius: 16
|
||||
border.color: theme.colors.border
|
||||
border.width: 1
|
||||
|
||||
Column {
|
||||
id: listContent
|
||||
|
||||
width: parent.width
|
||||
visible: !root.showEmptyState && !root.loading
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 48
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: qsTr("Position")
|
||||
color: theme.colors.textSecondary
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: qsTr("Pool share")
|
||||
color: theme.colors.textSecondary
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.colors.divider
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.positions || []
|
||||
|
||||
delegate: PositionRow {
|
||||
width: listContent.width
|
||||
showDivider: index < root.positionCount - 1
|
||||
objectName: "positionRow%1".arg(index)
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 8
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: emptyState
|
||||
|
||||
objectName: "positionsListEmptyState"
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 40
|
||||
visible: root.loading || root.showEmptyState
|
||||
text: root.loading ? qsTr("Loading positions…") : root.emptyStateText()
|
||||
color: root.loadError.length > 0 && !root.loading
|
||||
? theme.colors.error : theme.colors.textSecondary
|
||||
font.pixelSize: 14
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// Only pools the app knows about can be matched, so a missing
|
||||
// position is a config gap rather than a missing balance.
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.walletOpen && !root.loading
|
||||
text: qsTr("Positions are matched against the pools in the app's pool config.")
|
||||
color: theme.colors.textPlaceholder
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component PositionRow: Item {
|
||||
id: row
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
property bool showDivider: false
|
||||
readonly property var position: modelData
|
||||
readonly property string pairText: qsTr("%1 / %2")
|
||||
.arg(String(position.tokenA || ""))
|
||||
.arg(String(position.tokenB || ""))
|
||||
readonly property string feeText: root.feeLabel(position.feeBps)
|
||||
readonly property string shareText: root.shareText(position.share)
|
||||
readonly property string underlyingText: qsTr("%1 %2 · %3 %4")
|
||||
.arg(root.amountText(position.amountA))
|
||||
.arg(String(position.tokenA || ""))
|
||||
.arg(root.amountText(position.amountB))
|
||||
.arg(String(position.tokenB || ""))
|
||||
|
||||
height: 76
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: qsTr("%1, %2 fee, %3 of the pool")
|
||||
.arg(row.pairText).arg(row.feeText).arg(row.shareText)
|
||||
Accessible.onPressAction: row.activate()
|
||||
|
||||
function activate() {
|
||||
root.positionActivated(row.position)
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: row.activate()
|
||||
Keys.onEnterPressed: row.activate()
|
||||
Keys.onSpacePressed: row.activate()
|
||||
|
||||
// Hover/focus tint sits behind the content so the row reads as clickable.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: theme.colors.panelHoverBg
|
||||
opacity: rowMouse.containsMouse || row.activeFocus ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 120 }
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rowMouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: row.activate()
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 20
|
||||
anchors.rightMargin: 20
|
||||
spacing: 12
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: 46
|
||||
Layout.preferredHeight: 30
|
||||
Accessible.ignored: true
|
||||
|
||||
TokenAvatar {
|
||||
x: 0
|
||||
y: 1
|
||||
symbol: String(row.position.tokenA || "")
|
||||
z: 1
|
||||
}
|
||||
|
||||
TokenAvatar {
|
||||
x: 18
|
||||
y: 1
|
||||
symbol: String(row.position.tokenB || "")
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: row.pairText
|
||||
color: theme.colors.textPrimary
|
||||
font.pixelSize: 16
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: implicitWidth
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: feePillText.implicitWidth + 16
|
||||
Layout.preferredHeight: 22
|
||||
radius: 6
|
||||
color: theme.colors.inputBg
|
||||
border.color: theme.colors.borderStrong
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
id: feePillText
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: row.feeText
|
||||
color: theme.colors.textSecondary
|
||||
font.pixelSize: 11
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: row.underlyingText
|
||||
color: theme.colors.textSecondary
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: row.shareText
|
||||
color: theme.colors.textPrimary
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.Medium
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
visible: row.showDivider
|
||||
height: 1
|
||||
color: theme.colors.divider
|
||||
}
|
||||
}
|
||||
|
||||
component TokenAvatar: Rectangle {
|
||||
id: avatar
|
||||
|
||||
required property string symbol
|
||||
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
color: TokenVisuals.colorFor(symbol)
|
||||
border.color: theme.colors.cardBg
|
||||
border.width: 2
|
||||
Accessible.ignored: true
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: TokenVisuals.letterFor(avatar.symbol)
|
||||
color: "#FFFFFF"
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.Bold
|
||||
Accessible.ignored: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,7 @@ Item {
|
||||
|
||||
TokenSelectorModal {
|
||||
id: tokenModal
|
||||
objectName: "tokenSelectorModal"
|
||||
z: 10
|
||||
theme: pageTheme
|
||||
tokens: root.tokens
|
||||
|
||||
@@ -173,8 +173,11 @@ test("amm liquidity: add to the A/B pool", async (app) => {
|
||||
const before = await readReserveA(app);
|
||||
console.log(` A/B reserveA before: ${before.reserveA}`);
|
||||
|
||||
// 1. Switch to the Liquidity tab and wait for the form.
|
||||
await ignore(() => app.click("Liquidity"));
|
||||
// 1. Open the create-pool view (Pool > Create pool = tab 2, sub 1). Driving the
|
||||
// navbar's select() fires tabChanged, which also resets the form. (The old
|
||||
// "Liquidity" tab is now an entry under the "Pool" dropdown.)
|
||||
const navBarId = await idByObjectName(app, "navBar");
|
||||
await evaluate(app, navBarId, "select(2, 1)");
|
||||
await app.waitFor(
|
||||
async () => { await idByObjectName(app, "newPositionForm"); },
|
||||
{ timeout: 10000, interval: 300, description: "liquidity form to render" },
|
||||
|
||||
@@ -145,10 +145,12 @@ test("amm liquidity: create the A/C pool", async (app) => {
|
||||
|
||||
// 1. Switch to the Liquidity tab and wait for the form to render.
|
||||
await app.waitFor(
|
||||
async () => { await app.expectTexts(["Trade", "Liquidity"]); },
|
||||
async () => { await app.expectTexts(["Trade", "Pool"]); },
|
||||
{ timeout: 20000, interval: 500, description: "nav bar to load" },
|
||||
);
|
||||
await ignore(() => app.click("Liquidity"));
|
||||
// "Liquidity" is now "Pool > Create pool" (tab 2, sub 1); drive the navbar directly.
|
||||
const navBarId = await idByObjectName(app, "navBar");
|
||||
await evaluate(app, navBarId, "select(2, 1)");
|
||||
// waitFor resolves when the condition stops throwing — it does NOT return the
|
||||
// callback's value, so fetch the id with a direct call afterwards.
|
||||
await app.waitFor(
|
||||
|
||||
@@ -113,10 +113,12 @@ test("amm liquidity: add a custom (unlisted) token by id", async (app) => {
|
||||
|
||||
// 1. Switch to the Liquidity tab and wait for the form + page to render.
|
||||
await app.waitFor(
|
||||
async () => { await app.expectTexts(["Trade", "Liquidity"]); },
|
||||
async () => { await app.expectTexts(["Trade", "Pool"]); },
|
||||
{ timeout: 20000, interval: 500, description: "nav bar to load" },
|
||||
);
|
||||
await ignore(() => app.click("Liquidity"));
|
||||
// "Liquidity" is now "Pool > Create pool" (tab 2, sub 1); drive the navbar directly.
|
||||
const navBarId = await idByObjectName(app, "navBar");
|
||||
await evaluate(app, navBarId, "select(2, 1)");
|
||||
await app.waitFor(
|
||||
async () => { await idByObjectName(app, "newPositionForm"); },
|
||||
{ timeout: 10000, interval: 300, description: "liquidity form to render" },
|
||||
|
||||
Reference in New Issue
Block a user