mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(apps/amm): pick the token account per side when creating a pool
Add per-side account selectors to the create-pool form, sourced from the wallet's token holdings, so a user holding a token in several accounts chooses which funds each deposit. A single holding auto-selects. Mirrors the swap card: the selector lives inside the token input card, below the token button. AmmTokenAmountSurface gains an optional footer slot (unchanged when unset, so add-liquidity renders as before); TokenAmountInput fills it with a ProgramAccountSelector filtered on the token's base58 definitionId. - LiquidityPage fetches backend.tokenHoldings(), refetching when the wallet opens - NewPositionForm routes the chosen holdings into submissionSnapshot's createPool call; canConfirm now requires both holdings when creating a pool (add-liquidity is untouched — it enumerates holdings server-side) - create-pool.mjs selects the funding account for each side before submitting
This commit is contained in:
@@ -22,12 +22,20 @@ Rectangle {
|
||||
property Component adjustment
|
||||
property real adjustmentWidth: 0
|
||||
property real adjustmentHeight: 0
|
||||
// Optional full-width content rendered inside the card, below the amount/token
|
||||
// row (e.g. the create-pool account selector). Null → the card is unchanged.
|
||||
property Component footer
|
||||
property real footerHeight: 0
|
||||
readonly property bool footerActive: root.footer !== null
|
||||
property alias footerItem: footerLoader.item
|
||||
|
||||
signal amountEdited(string value)
|
||||
signal amountEditingFinished(string value)
|
||||
signal supportingActionClicked
|
||||
|
||||
implicitHeight: Math.max(110, contentRow.implicitHeight + 24)
|
||||
implicitHeight: root.footerActive
|
||||
? Math.max(110, contentRow.implicitHeight + root.footerHeight + 30)
|
||||
: Math.max(110, contentRow.implicitHeight + 24)
|
||||
radius: 16
|
||||
color: root.muted ? root.theme.colors.panelBg : root.theme.colors.inputBg
|
||||
border.color: root.invalid
|
||||
@@ -46,11 +54,16 @@ Rectangle {
|
||||
RowLayout {
|
||||
id: contentRow
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
// When a footer is present it takes the bottom of the card; otherwise the
|
||||
// row fills the card exactly as before (12px inset).
|
||||
anchors.bottom: footerLoader.top
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
anchors.topMargin: 12
|
||||
anchors.bottomMargin: 12
|
||||
anchors.bottomMargin: root.footerActive ? 6 : 12
|
||||
spacing: 10
|
||||
|
||||
ColumnLayout {
|
||||
@@ -189,6 +202,21 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: footerLoader
|
||||
|
||||
active: root.footerActive
|
||||
visible: active
|
||||
sourceComponent: root.footer
|
||||
height: active ? root.footerHeight : 0
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
anchors.bottomMargin: active ? 12 : 0
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 180 }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ AmmActionCard {
|
||||
|
||||
property var newPositionContext: ({})
|
||||
property var flowState: ({})
|
||||
// Wallet token holdings (backend.tokenHoldings()) for the create-pool account
|
||||
// selectors, narrowed per side by the selected token's base58 definitionId. The
|
||||
// chosen holdings feed the createPool call via submissionSnapshot().
|
||||
property var holdings: []
|
||||
readonly property string selectedHoldingAId: tokenAInput.selectedHoldingId
|
||||
readonly property string selectedHoldingBId: tokenBInput.selectedHoldingId
|
||||
property string selectedTokenAId: ""
|
||||
property string selectedTokenBId: ""
|
||||
property int selectedFeeBps: 30
|
||||
@@ -86,10 +92,17 @@ AmmActionCard {
|
||||
&& root.selectedTokenBId.length > 0
|
||||
&& root.selectedTokenAId !== root.selectedTokenBId
|
||||
readonly property bool resolvingToken: root.resolvingTokenId.length > 0
|
||||
// Creating a pool submits caller-provided A/B holdings (see submissionSnapshot);
|
||||
// require both selectors resolved. Add-liquidity enumerates holdings server-side,
|
||||
// so it does not gate on these.
|
||||
readonly property bool holdingsReady: !root.missingPool
|
||||
|| (root.selectedHoldingAId.length > 0
|
||||
&& root.selectedHoldingBId.length > 0)
|
||||
readonly property bool canConfirm: root.quotePayload.status === "ok"
|
||||
&& root.quotePayload.canSubmit === true
|
||||
&& root.quoteMatchesPair()
|
||||
&& String(root.quotePayload.quoteHash || "").length > 0
|
||||
&& root.holdingsReady
|
||||
&& !root.contextLoading
|
||||
&& !root.quoteLoading
|
||||
&& !root.quoteStale
|
||||
@@ -248,6 +261,10 @@ AmmActionCard {
|
||||
tokenData: root.tokenA.definitionId ? root.tokenA : null
|
||||
tokens: root.tokens
|
||||
selectedTokenId: root.selectedTokenAId
|
||||
holdings: root.holdings
|
||||
holdingDefinitionId: root.selectedTokenAId
|
||||
showHoldingSelector: root.missingPool && root.hasPair
|
||||
selectorObjectName: "newPositionAccountSelectorA"
|
||||
tokenInvalid: root.tokenHasError("A")
|
||||
tokenSelectionEnabled: !root.contextLoading && !root.submitting
|
||||
adjustment: root.missingPool ? priceAmountAAdjustment : null
|
||||
@@ -296,6 +313,10 @@ AmmActionCard {
|
||||
tokenData: root.tokenB.definitionId ? root.tokenB : null
|
||||
tokens: root.tokens
|
||||
selectedTokenId: root.selectedTokenBId
|
||||
holdings: root.holdings
|
||||
holdingDefinitionId: root.selectedTokenBId
|
||||
showHoldingSelector: root.missingPool && root.hasPair
|
||||
selectorObjectName: "newPositionAccountSelectorB"
|
||||
tokenInvalid: root.tokenHasError("B")
|
||||
tokenSelectionEnabled: !root.contextLoading && !root.submitting
|
||||
adjustment: root.missingPool ? priceAmountBAdjustment : null
|
||||
@@ -1440,8 +1461,10 @@ AmmActionCard {
|
||||
// Canonical-order holdings for the create path's createPool call: the
|
||||
// request's tokenAId/amountARaw are canonical, so holdingAId must be the
|
||||
// canonical token A's holding too (createPool re-canonicalizes as a no-op).
|
||||
"holdingAId": String((root.displayIsCanonical ? root.tokenA : root.tokenB).holdingId || ""),
|
||||
"holdingBId": String((root.displayIsCanonical ? root.tokenB : root.tokenA).holdingId || ""),
|
||||
// The user picks these via the per-side account selectors; selectedHoldingA
|
||||
// is display token A's holding, so it aligns with tokenA the same way.
|
||||
"holdingAId": String(root.displayIsCanonical ? root.selectedHoldingAId : root.selectedHoldingBId),
|
||||
"holdingBId": String(root.displayIsCanonical ? root.selectedHoldingBId : root.selectedHoldingAId),
|
||||
"pairText": qsTr("%1 / %2").arg(root.shortTokenName(root.tokenA)).arg(root.shortTokenName(root.tokenB)),
|
||||
"feeText": root.feeLabel(root.selectedFeeBps),
|
||||
"depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"),
|
||||
|
||||
@@ -2,6 +2,8 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
|
||||
import Logos.Wallet
|
||||
|
||||
import "../shared"
|
||||
|
||||
AmmTokenAmountSurface {
|
||||
@@ -18,6 +20,21 @@ AmmTokenAmountSurface {
|
||||
property bool tokenSelectionEnabled: true
|
||||
property bool editPending: false
|
||||
property string pendingValue: ""
|
||||
// Account selector (create-pool only): the wallet holdings to pick from, the
|
||||
// token's base58 definitionId to filter by, and whether to show it at all. The
|
||||
// chosen holding id is exposed as selectedHoldingId. Mirrors the swap card, where
|
||||
// the selector sits inside the input card below the token button.
|
||||
property var holdings: []
|
||||
property string holdingDefinitionId: ""
|
||||
property bool showHoldingSelector: false
|
||||
// objectName forwarded to the account selector, so UI tests can pick the
|
||||
// funding holding for this side deterministically.
|
||||
property string selectorObjectName: ""
|
||||
readonly property string selectedHoldingId: root.footerItem && root.footerItem.selectedAccountId
|
||||
? String(root.footerItem.selectedAccountId) : ""
|
||||
|
||||
footer: root.showHoldingSelector ? accountFooter : null
|
||||
footerHeight: root.footerItem ? root.footerItem.implicitHeight : 0
|
||||
property var disabledReasonForCode: function(code) {
|
||||
return qsTr("This token is unavailable (%1).").arg(code || "unknown")
|
||||
}
|
||||
@@ -65,6 +82,40 @@ AmmTokenAmountSurface {
|
||||
onTriggered: root.commitPendingEdit()
|
||||
}
|
||||
|
||||
Component {
|
||||
id: accountFooter
|
||||
|
||||
// The Loader stretches this wrapper to the card width; the selector takes the
|
||||
// right half, right-aligned, matching the swap card's account selector.
|
||||
Item {
|
||||
implicitHeight: footerSelector.implicitHeight
|
||||
|
||||
property alias selectedAccountId: footerSelector.selectedAccountId
|
||||
|
||||
ProgramAccountSelector {
|
||||
id: footerSelector
|
||||
|
||||
objectName: root.selectorObjectName
|
||||
width: Math.round(parent.width / 2)
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
sourceModel: root.holdings
|
||||
accountType: "TokenHolding"
|
||||
stateField: "definitionId"
|
||||
stateValue: root.holdingDefinitionId
|
||||
selectionMode: ProgramAccountSelector.Input
|
||||
showWhenSingle: true
|
||||
textAlignment: Text.AlignRight
|
||||
backgroundColor: root.theme.colors.panelBg
|
||||
hoverColor: root.theme.colors.panelHoverBg
|
||||
textColor: root.theme.colors.textPrimary
|
||||
secondaryTextColor: root.theme.colors.textSecondary
|
||||
borderColor: root.theme.colors.borderStrong
|
||||
focusColor: root.theme.colors.ctaBg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: tokenActions
|
||||
|
||||
|
||||
@@ -17,6 +17,27 @@ Item {
|
||||
property var runtime: null
|
||||
readonly property NewPositionFlow flow: newPositionFlow
|
||||
|
||||
// Wallet token holdings (backend.tokenHoldings()) feeding the create-pool
|
||||
// account selectors; refetched when the wallet opens.
|
||||
property var holdings: []
|
||||
|
||||
function refreshHoldings() {
|
||||
if (!root.backend || root.runtime === null)
|
||||
return
|
||||
root.runtime.watch(root.backend.tokenHoldings(),
|
||||
function(list) { root.holdings = list },
|
||||
function(err) { console.warn("tokenHoldings error:", err) })
|
||||
}
|
||||
|
||||
onBackendChanged: root.refreshHoldings()
|
||||
onRuntimeChanged: root.refreshHoldings()
|
||||
Component.onCompleted: root.refreshHoldings()
|
||||
|
||||
Connections {
|
||||
target: root.backend
|
||||
function onIsWalletOpenChanged() { root.refreshHoldings() }
|
||||
}
|
||||
|
||||
readonly property int pageMargin: width < 640 ? 16 : 24
|
||||
readonly property int contentMaxWidth: 1200
|
||||
readonly property bool wideLayout: width >= 760
|
||||
@@ -215,6 +236,7 @@ Item {
|
||||
? qsTr("Specify the token amounts for your liquidity contribution.")
|
||||
: qsTr("Choose two tokens and a fee tier for this position.")
|
||||
showRefreshAction: false
|
||||
holdings: root.holdings
|
||||
newPositionContext: newPositionFlow.newPositionContext
|
||||
flowState: newPositionFlow.viewState
|
||||
|
||||
|
||||
@@ -63,9 +63,34 @@ async function formState(app, formId) {
|
||||
amountB: get("amountB"),
|
||||
submitError: get("submitError"),
|
||||
transactionId: get("transactionId"),
|
||||
selectedHoldingAId: get("selectedHoldingAId"),
|
||||
selectedHoldingBId: get("selectedHoldingBId"),
|
||||
};
|
||||
}
|
||||
|
||||
// Pick the funding account for a create-pool side. The selector auto-selects a
|
||||
// single holding, but choose it explicitly (robust to multi-account wallets):
|
||||
// wait for the holdings to populate, then select the first match. canConfirm now
|
||||
// requires both A/B holdings before a pool can be created.
|
||||
async function selectAccount(app, selectorObjectName) {
|
||||
// The selector lives in a Loader that instantiates only once the pool is known
|
||||
// to be missing, so it may render a frame after missingPool flips — wait for it.
|
||||
let id;
|
||||
await app.waitFor(
|
||||
async () => { id = await idByObjectName(app, selectorObjectName); },
|
||||
{ timeout: 10000, interval: 300, description: `${selectorObjectName} to render` },
|
||||
);
|
||||
await app.waitFor(
|
||||
async () => { if ((await prop(app, id, "hasFunds")) !== true) throw new Error("no matching holdings yet"); },
|
||||
{ timeout: 10000, interval: 300, description: `${selectorObjectName} holdings to load` },
|
||||
);
|
||||
await evaluate(app, id, "setSelection(accountIdFor(matchingAccounts[0]), false)");
|
||||
await app.waitFor(
|
||||
async () => { if (!(await prop(app, id, "selectedAccountId"))) throw new Error("holding not selected yet"); },
|
||||
{ timeout: 5000, interval: 200, description: `${selectorObjectName} holding selected` },
|
||||
);
|
||||
}
|
||||
|
||||
async function saveShot(app, name) {
|
||||
const shot = await ignore(() => app.screenshot());
|
||||
if (shot && shot.image) {
|
||||
@@ -154,13 +179,24 @@ test("amm liquidity: create the A/C pool", async (app) => {
|
||||
);
|
||||
await evaluate(app, formId, "requestQuote(true)");
|
||||
|
||||
// 3. Wait for a submittable create quote (missing pool + funded minimum deposit).
|
||||
// 3. Wait for the missing-pool quote (which makes the per-side account selectors
|
||||
// render), pick the funding account for each side, then wait for a submittable
|
||||
// create quote — canConfirm needs the funded minimum deposit AND both holdings.
|
||||
try {
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const s = await formState(app, formId);
|
||||
if (s.poolStatus === "active_pool")
|
||||
throw new Error("A/C pool already exists — reset the testnet (only A/B should be seeded)");
|
||||
if (!s.missingPool) throw new Error("pool status not resolved yet");
|
||||
},
|
||||
{ timeout: 20000, interval: 500, description: "missing-pool quote" },
|
||||
);
|
||||
await selectAccount(app, "newPositionAccountSelectorA");
|
||||
await selectAccount(app, "newPositionAccountSelectorB");
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const s = await formState(app, formId);
|
||||
if (!s.canConfirm) throw new Error("create CTA not ready yet");
|
||||
},
|
||||
{ timeout: 20000, interval: 500, description: "create CTA ready" },
|
||||
|
||||
@@ -266,8 +266,14 @@ TestCase {
|
||||
verify(amountBInput)
|
||||
compare(amountAInput.selectedTokenId, tokenLow)
|
||||
compare(amountBInput.selectedTokenId, tokenHigh)
|
||||
verify(amountAInput.height <= 114)
|
||||
verify(amountBInput.height <= 114)
|
||||
// missing_pool now renders the holding-selector footer, so the input card grows by the
|
||||
// footer's own height plus its extra bottom spacing (the surface adds footerHeight + 30
|
||||
// when a footer is active vs + 24 without one, i.e. footerHeight + 6 over the pre-footer
|
||||
// bound). Keep the compactness guard, but make it footer-aware.
|
||||
verify(amountAInput.footerActive) // the holding-selector footer is present in missing_pool
|
||||
verify(amountBInput.footerActive)
|
||||
verify(amountAInput.height <= 114 + amountAInput.footerHeight + 6)
|
||||
verify(amountBInput.height <= 114 + amountBInput.footerHeight + 6)
|
||||
verify(findChild(form, "priceAmountAField"))
|
||||
verify(findChild(form, "priceAmountBField"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user