feat(apps/amm): create pools via the new createPool op; drop the pool-watch poll

Route pool creation through the redesigned createPool op and retire the old
create-flow machinery. Add-liquidity stays on the legacy submitNewPosition.

Module (amm_module):
- createPool becomes a single-param envelope: it reads the caller-provided
  lpHoldingId from the request (a new pool has no pre-existing LP holding),
  dropping the requires-fresh-lp handshake — the module never creates wallet
  accounts. Returns { status, error, transactionId }; unlike the swaps, a
  submit failure carries a code so the UI can explain why.

Backend (AmmUiBackend):
- Expose liquidityQuote (read-only preview) and createPool slots. createPool
  guards on the app's wallet-open state, forwards the request, and refreshes
  balances on success. No account creation here.

UI (liquidity flow/form):
- NewPositionFlow.confirm() branches on the missing-pool signal: create ->
  createPool (mint a fresh public LP account via createAccountPublic, then
  submit); add -> unchanged submitNewPosition. Hex transactionId accepted.
- submissionSnapshot supplies canonical-order holdingAId/holdingBId.
- (liquidityQuote is wired to the backend but the create preview still rides
  the legacy quote for now.)

Cleanup: the pool-creation confirmation poll is orphaned now that create no
longer submits via submitNewPosition. It polled the pool account by re-quoting
until poolStatus flipped to active_pool — a stopgap for the missing
transactionStatus/poll_tx on the lez module. Removed pendingPoolProbes,
poolPoller, watchPoolCreation, pollPendingPool, finishPoolProbe,
rotate/removePendingPool, pairKey, matchesSelectedPair,
selectedPoolCreationPending, poolActivated, the poolCreationPending state,
acceptPoolActivation, and their now-obsolete tests.
This commit is contained in:
r4bbit
2026-08-11 12:04:48 +02:00
parent 526d50bff1
commit e1398ffcad
8 changed files with 125 additions and 254 deletions
@@ -48,7 +48,6 @@ AmmActionCard {
readonly property bool quoteLoading: root.flowState.quoteLoading === true
readonly property bool submitting: root.flowState.submitting === true
readonly property bool quoteStale: root.flowState.quoteStale === true
readonly property bool poolCreationPending: root.flowState.poolCreationPending === true
readonly property string submitError: root.flowState.errorCode
? root.issueText(root.flowState.errorCode) : ""
readonly property string transactionId: String(root.flowState.transactionId || "")
@@ -95,7 +94,11 @@ AmmActionCard {
&& !root.quoteLoading
&& !root.quoteStale
&& !root.submitting
&& !root.poolCreationPending
// A create-pool tx for this pair is already in flight
// (transactionId set) but the pool still reads missing until
// the chain processes it — block confirm so a stale
// missing_pool quote can't submit a duplicate NewDefinition.
&& !(root.missingPool && root.transactionId.length > 0)
signal quoteRequested(bool immediate, var quoteRequest)
signal confirmationRequested(var snapshot)
@@ -600,7 +603,6 @@ AmmActionCard {
text: root.submitting
? qsTr("Submitting…")
: root.contextLoading ? qsTr("Loading…")
: root.poolCreationPending ? qsTr("Waiting for pool")
: root.missingPool ? qsTr("Create pool") : qsTr("Add liquidity")
enabled: root.canConfirm
onClicked: root.confirmationRequested(root.submissionSnapshot())
@@ -856,22 +858,6 @@ AmmActionCard {
root.requestQuote(true)
}
function acceptPoolActivation(quote) {
if (!quote || quote.status !== "ok"
|| quote.poolStatus !== "active_pool"
|| !root.quoteMatchesSelectedPair(quote)) {
return false
}
root.confirmedPoolStatus = "active_pool"
root.activePoolQuote = quote
root.amountA = ""
root.amountB = ""
root.minimumAmountARaw = ""
root.minimumAmountBRaw = ""
root.localErrors = []
return true
}
function noteDraftChanged() {
root.draftChanged()
}
@@ -1449,8 +1435,12 @@ AmmActionCard {
var built = root.buildQuoteRequest()
return {
"request": built.request,
"poolProbeRequest": root.poolProbeRequest(built.request),
"quoteHash": String(root.quotePayload.quoteHash || ""),
// 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 || ""),
"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"),
-4
View File
@@ -248,10 +248,6 @@ Item {
form.failTokenResolution(code)
}
function onPoolActivated(quote) {
form.acceptPoolActivation(quote)
}
function onQuoteRefreshRequested(immediate) {
form.requestQuote(immediate)
}
+60 -96
View File
@@ -20,7 +20,6 @@ QtObject {
"quoteLoading": root.quoteLoading,
"quoteStale": root.quoteStale,
"submitting": root.submitting,
"poolCreationPending": root.selectedPoolCreationPending(),
"transactionId": root.transactionId,
"errorCode": root.flowErrorCode || root.contextErrorCode
|| root.quoteErrorCode
@@ -39,12 +38,9 @@ QtObject {
property string contextErrorCode: ""
property string quoteErrorCode: ""
property var pendingQuoteRequest: ({ "ok": false, "request": ({}) })
property var pendingPoolProbes: []
property bool poolProbeInFlight: false
signal tokenResolutionFinished(bool finalResponse)
signal tokenResolutionFailed(string code)
signal poolActivated(var quote)
signal quoteRefreshRequested(bool immediate)
signal submitSucceeded
signal submitFailed
@@ -57,13 +53,6 @@ QtObject {
onTriggered: root.requestQuoteNow(root.quoteSerial)
}
property Timer poolPoller: Timer {
interval: 5000
repeat: true
running: root.pendingPoolProbes.length > 0
onTriggered: root.pollPendingPool()
}
onNewPositionContextChanged: root.invalidateQuote()
onWalletStateReadyChanged: {
@@ -207,13 +196,19 @@ QtObject {
return
}
// Pool creation (initialPriceRealRaw is set only on the missing-pool path) goes
// through the new createPool op — hex ids, caller-provided accounts. Add-liquidity
// keeps the legacy submitNewPosition.
if (snapshot.request.initialPriceRealRaw !== undefined) {
root.createPool(snapshot)
return
}
root.runtime.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash),
function(result) {
if (result && result.status === "submitted"
&& /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(
String(result.transactionId || ""))) {
if (snapshot.request.initialPriceRealRaw !== undefined)
root.watchPoolCreation(snapshot.poolProbeRequest, result.deadlineMs)
root.submitting = false
root.transactionId = result.transactionId
root.flowErrorCode = ""
@@ -230,6 +225,58 @@ QtObject {
})
}
// Create a pool via the new createPool op. A new pool has no pre-existing LP
// holding, so the caller provides a fresh account: create one, then submit. No
// confirmation poll yet (transactionStatus is pending an upstream dependency).
function createPool(snapshot) {
root.runtime.watch(root.backend.createAccountPublic(),
function(lpId) {
if (!lpId || String(lpId).length === 0) {
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
return
}
root.submitCreatePool(snapshot, String(lpId))
},
function(error) {
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
})
}
function submitCreatePool(snapshot, lpHoldingId) {
var request = {
"tokenAId": snapshot.request.tokenAId,
"tokenBId": snapshot.request.tokenBId,
"holdingAId": snapshot.holdingAId,
"holdingBId": snapshot.holdingBId,
"lpHoldingId": lpHoldingId,
"amountARaw": snapshot.request.amountARaw,
"amountBRaw": snapshot.request.amountBRaw,
"feeBps": snapshot.request.feeBps,
// u64-max sentinel = no deadline, same as the swap submits.
"deadlineMs": "18446744073709551615"
}
root.runtime.watch(root.backend.createPool(request),
function(result) {
if (result && result.status === "ok"
&& String(result.transactionId || "").length > 0) {
root.submitting = false
root.transactionId = String(result.transactionId)
root.flowErrorCode = ""
root.contextErrorCode = ""
root.quoteErrorCode = ""
root.invalidateQuote()
root.submitSucceeded()
return
}
var code = result && result.error ? String(result.error)
: "wallet_submission_failed"
root.finishSubmitFailure(root.quoteError(code))
},
function(error) {
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
})
}
function finishSubmitFailure(result) {
root.submitting = false
const hasFreshQuote = result && result.quote
@@ -247,89 +294,6 @@ QtObject {
root.scheduleQuote(true, root.pendingQuoteRequest)
}
function watchPoolCreation(request, deadlineMs) {
const key = root.pairKey(request)
let deadline = Number(deadlineMs)
if (!isFinite(deadline) || deadline <= 0)
deadline = 0
const pending = root.pendingPoolProbes.filter(function(item) {
return item.key !== key
})
pending.push({
"key": key,
"request": request,
"deadlineMs": deadline
})
root.pendingPoolProbes = pending
Qt.callLater(root.pollPendingPool)
}
function pollPendingPool() {
if (root.poolProbeInFlight || root.pendingPoolProbes.length === 0
|| !root.walletStateReady || root.runtime === null) {
return
}
const pending = root.pendingPoolProbes[0]
root.poolProbeInFlight = true
root.runtime.watch(root.backend.quoteNewPosition(pending.request),
function(quote) {
root.finishPoolProbe(pending, quote)
},
function(error) {
root.finishPoolProbe(pending, null)
})
}
function finishPoolProbe(pending, quote) {
root.poolProbeInFlight = false
if (quote && quote.poolStatus === "active_pool") {
root.removePendingPool(pending.key)
if (root.matchesSelectedPair(pending.request)) {
root.poolActivated(quote)
root.invalidateQuote()
root.refreshContext(true)
}
return
}
if (pending.deadlineMs > 0 && Date.now() >= pending.deadlineMs) {
root.removePendingPool(pending.key)
return
}
root.rotatePendingPool()
}
function pairKey(request) {
return String(request.tokenAId || "") + ":" + String(request.tokenBId || "")
}
function matchesSelectedPair(request) {
return root.pairKey(root.pendingQuoteRequest.request || {}) === root.pairKey(request)
}
function selectedPoolCreationPending() {
const request = root.pendingQuoteRequest.request || {}
if (!request.tokenAId || !request.tokenBId)
return false
const selected = root.pairKey(request)
return root.pendingPoolProbes.some(function(item) {
return item.key === selected
})
}
function removePendingPool(key) {
root.pendingPoolProbes = root.pendingPoolProbes.filter(function(item) {
return item.key !== key
})
}
function rotatePendingPool() {
if (root.pendingPoolProbes.length < 2)
return
const pending = root.pendingPoolProbes.slice(1)
pending.push(root.pendingPoolProbes[0])
root.pendingPoolProbes = pending
}
function draftChanged() {
root.invalidateQuote()
root.transactionId = ""
+27
View File
@@ -271,3 +271,30 @@ QVariantList AmmUiBackend::tokenList()
{
return m_logos->amm_module.tokenList();
}
QVariantMap AmmUiBackend::liquidityQuote(QVariantMap request)
{
// Read-only create-pool preview — no wallet guard. The module prices the opening
// LP and price server-side from the two deposit amounts.
return m_logos->amm_module.liquidityQuote(request);
}
QVariantMap AmmUiBackend::createPool(QVariantMap request)
{
// Same connected-state submit guard as the swaps — 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") },
};
// The caller supplies the fresh LP holding in the request; the backend forwards to
// the module (it creates no wallet accounts) and refreshes balances on a successful
// submit.
const QVariantMap result = m_logos->amm_module.createPool(request);
if (result.value(QStringLiteral("status")).toString() == QStringLiteral("ok")
&& !result.value(QStringLiteral("transactionId")).toString().isEmpty())
refreshBalances();
return result;
}
+6
View File
@@ -71,6 +71,12 @@ public slots:
// Reads the token list from TOKENS_CONFIG (via the module) so the Swap UI's
// token picker is config-driven instead of hardcoded.
QVariantList tokenList() override;
// Create-pool preview (liquidityQuote, read-only) and submit (createPool). The caller
// supplies lpHoldingId in the request — a fresh account it created via
// createAccountPublic() — so createPool forwards to the module and creates no wallet
// accounts here.
QVariantMap liquidityQuote(QVariantMap request) override;
QVariantMap createPool(QVariantMap request) override;
private:
void syncWalletState();
+22
View File
@@ -92,4 +92,26 @@ class AmmUiBackend
// a QVariantList of QVariantMap entries. Returns an empty list if
// TOKENS_CONFIG is unset/unreadable/invalid.
SLOT(QVariantList tokenList())
// Server-side create-pool preview from the two deposit amounts. `request`
// carries { tokenAId, tokenBId, amountARaw, amountBRaw } (ids hex or base58;
// amounts decimal-string base units). Returns { status:"ok", error:"",
// amountARaw, amountBRaw, expectedLpRaw, lockedLpRaw, initialPriceRaw } — the
// LP the creator receives and the opening price, via the shared on-chain math.
// On failure { status:"error", error:<code> } — invalid_token_id,
// same_token_pair, amount_too_low, amount_required, bad_amount, backend_error.
// Read-only, no submission (the fee is not needed — it isn't part of the pool
// PDA nor the pricing).
SLOT(QVariantMap liquidityQuote(QVariantMap request))
// Submits a NewDefinition transaction creating the pool for the request's pair.
// `request` carries { tokenAId, tokenBId, holdingAId, holdingBId, lpHoldingId,
// amountARaw, amountBRaw, feeBps, deadlineMs } (ids hex or base58; amounts/deadline
// decimal strings). A new pool has no existing LP holding, so the caller supplies
// lpHoldingId — a fresh account it created via createAccountPublic(); the backend
// just forwards to the module and creates no wallet accounts here. Returns
// { status:"ok", error:"", transactionId:<hex tx hash> } on success, else
// { status:"error", error:<code> } (wallet_unavailable, config_missing,
// invalid_account_id, bad_amount, bad_fee_bps_amount, invalid_fee_tier,
// wallet_submission_failed, backend_error).
SLOT(QVariantMap createPool(QVariantMap request))
}
-104
View File
@@ -191,44 +191,6 @@ TestCase {
compare(page.flow.submitting, false)
}
function test_base58MissingPoolSubmissionStartsPoolWatch() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
"submitResult": {
"status": "submitted",
"transactionId": submittedTransactionId,
"deadlineMs": String(Date.now() + 60000)
}
})
var runtime = createTemporaryObject(runtimeComponent, testCase)
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend,
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
var probe = {
"tokenAId": "22222222222222222222222222222222",
"tokenBId": "33333333333333333333333333333333"
}
page.flow.pendingQuoteRequest = { "ok": true, "request": probe }
page.flow.confirm({
"request": {
"initialPriceRealRaw": "18446744073709551616"
},
"poolProbeRequest": probe,
"quoteHash": "sha256:expected"
})
wait(0)
compare(page.flow.transactionId, submittedTransactionId)
compare(page.flow.pendingPoolProbes.length, 1)
compare(page.flow.selectedPoolCreationPending(), true)
compare(page.flow.poolProbeInFlight, false)
}
function test_nativeHexSubmittedResultDoesNotEnterSuccessState() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
@@ -257,70 +219,4 @@ TestCase {
compare(page.flow.submitting, false)
}
function test_poolProbeDoesNotPublishProbeAmountsAsCurrentQuote() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
var request = {
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"tokenBId": "22222222222222222222222222222222"
}
var pending = { "key": page.flow.pairKey(request), "request": request }
page.flow.pendingQuoteRequest = { "ok": true, "request": request }
page.flow.pendingPoolProbes = [pending]
page.flow.newPositionQuote = {
"status": "ok",
"poolStatus": "missing_pool",
"tokenAId": request.tokenAId,
"tokenBId": request.tokenBId
}
page.flow.quoteStale = false
page.flow.finishPoolProbe(pending, {
"status": "ok",
"poolStatus": "active_pool",
"tokenAId": request.tokenAId,
"tokenBId": request.tokenBId
})
compare(page.flow.pendingPoolProbes.length, 0)
compare(page.flow.newPositionQuote.poolStatus, "missing_pool")
verify(page.flow.quoteStale)
}
function test_poolProbeStopsBlockingAfterTransactionDeadline() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
var request = {
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"tokenBId": "22222222222222222222222222222222"
}
var pending = {
"key": page.flow.pairKey(request),
"request": request,
"deadlineMs": Date.now() - 1
}
page.flow.pendingQuoteRequest = { "ok": true, "request": request }
page.flow.pendingPoolProbes = [pending]
page.flow.poolProbeInFlight = true
page.flow.finishPoolProbe(pending, null)
compare(page.flow.pendingPoolProbes.length, 0)
compare(page.flow.poolProbeInFlight, false)
verify(!page.flow.selectedPoolCreationPending())
}
}
@@ -345,36 +345,6 @@ TestCase {
compare(form.amountA, "2")
}
function test_poolActivationClearsCreationDraftWithoutPublishingProbeAmounts() {
var form = createForm()
form.confirmedPoolStatus = "missing_pool"
form.amountA = "3"
form.amountB = "2"
form.minimumAmountARaw = "3"
form.minimumAmountBRaw = "2000000"
verify(form.acceptPoolActivation({
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "active_pool",
"reserveARaw": "3000000",
"reserveBRaw": "2"
}))
verify(form.activePool)
compare(form.activePoolQuote.poolStatus, "active_pool")
compare(form.amountA, "")
compare(form.amountB, "")
compare(form.minimumAmountARaw, "")
compare(form.minimumAmountBRaw, "")
quoteRequestedSpy.target = form
quoteRequestedSpy.clear()
form.requestQuote(true)
compare(quoteRequestedSpy.count, 0)
compare(form.localErrors.length, 0)
}
function test_staleQuoteErrorsDoNotMarkCurrentDraft() {
var quote = {
"status": "ok",