From ec18833e3a610c88411357bd861c5de60206b855 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 21:51:16 -0300 Subject: [PATCH] fix(amm): cancel superseded quote work --- apps/amm/qml/state/NewPositionFlow.qml | 5 +- apps/amm/src/AmmUiBackend.cpp | 5 +- apps/amm/src/AmmUiBackend.h | 3 +- apps/amm/src/AmmUiBackend.rep | 2 +- apps/amm/src/NewPositionRuntime.cpp | 41 ++++++++--- apps/amm/src/NewPositionRuntime.h | 3 + apps/amm/tests/cpp/NewPositionRuntimeTest.cpp | 71 ++++++++++++++++++- apps/amm/tests/qml/tst_LiquidityPage.qml | 23 +++++- 8 files changed, 137 insertions(+), 16 deletions(-) diff --git a/apps/amm/qml/state/NewPositionFlow.qml b/apps/amm/qml/state/NewPositionFlow.qml index c7faae9..95fb8af 100644 --- a/apps/amm/qml/state/NewPositionFlow.qml +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -182,7 +182,7 @@ QtObject { } root.activeQuoteRequestId = ++root.operationSerial root.backend.requestNewPositionQuote( - built.request, root.activeQuoteRequestId, true) + built.request, root.activeQuoteRequestId, true, false) } function acceptQuoteResult(result) { @@ -299,7 +299,8 @@ QtObject { const pending = root.pendingPoolProbes[0] root.poolProbeInFlight = true root.poolProbeRequestId = ++root.operationSerial - root.backend.requestNewPositionQuote(pending.request, root.poolProbeRequestId, true) + root.backend.requestNewPositionQuote( + pending.request, root.poolProbeRequestId, true, true) } function finishPoolProbe(pending, quote) { diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 714ce96..a4573c4 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -164,10 +164,11 @@ void AmmUiBackend::refreshNewPositionContext(QVariantMap request) void AmmUiBackend::requestNewPositionQuote(QVariantMap request, int requestId, - bool forceRefresh) + bool forceRefresh, + bool isPoolProbe) { m_newPosition->quoteAsync( - request, m_network.snapshot(), isWalletOpen(), forceRefresh, + request, m_network.snapshot(), isWalletOpen(), forceRefresh, isPoolProbe, [this, requestId](QVariantMap result) { result.insert(QStringLiteral("requestId"), requestId); setNewPositionQuoteResult(std::move(result)); diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 041a495..1ab9d0f 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -47,7 +47,8 @@ public slots: void refreshNewPositionContext(QVariantMap request) override; void requestNewPositionQuote(QVariantMap request, int requestId, - bool forceRefresh) override; + bool forceRefresh, + bool isPoolProbe) override; void requestNewPositionSubmit(QVariantMap request, QString quoteHash, int requestId) override; diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 78c7b10..8ccb626 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -36,7 +36,7 @@ class AmmUiBackend PROP(QVariantMap newPositionQuoteResult READONLY) PROP(QVariantMap newPositionSubmitResult READONLY) SLOT(void refreshNewPositionContext(QVariantMap request)) - SLOT(void requestNewPositionQuote(QVariantMap request, int requestId, bool forceRefresh)) + SLOT(void requestNewPositionQuote(QVariantMap request, int requestId, bool forceRefresh, bool isPoolProbe)) SLOT(void requestNewPositionSubmit(QVariantMap request, QString quoteHash, int requestId)) // Wallet lifecycle. createNewDefault() is the happy path: it creates a diff --git a/apps/amm/src/NewPositionRuntime.cpp b/apps/amm/src/NewPositionRuntime.cpp index 2323fe8..04b722a 100644 --- a/apps/amm/src/NewPositionRuntime.cpp +++ b/apps/amm/src/NewPositionRuntime.cpp @@ -451,8 +451,11 @@ void NewPositionRuntime::buildQuoteInputAsync( const ActiveNetworkSnapshot& network, bool walletOpen, bool forceRefresh, + std::function shouldContinue, std::function callback) { + if (!shouldContinue()) + return; if (network.status != QStringLiteral("ready")) { callback({}, publicError(network.status)); return; @@ -472,8 +475,9 @@ void NewPositionRuntime::buildQuoteInputAsync( QPointer guard(this); m_sequencer->readAccounts({ configId }, forceRefresh, [guard, request, network, walletOpen, forceRefresh, + shouldContinue, callback = std::move(callback)](QVector configReads) mutable { - if (!guard) + if (!guard || !shouldContinue()) return; const QJsonObject config = accountReadJson(configReads.value(0)); const QJsonObject requestObject = QJsonObject::fromVariantMap(request); @@ -509,9 +513,10 @@ void NewPositionRuntime::buildQuoteInputAsync( }; guard->m_sequencer->readAccounts(fixedIds, forceRefresh, [guard, requestObject, network, walletOpen, config, pair, + shouldContinue, callback = std::move(callback)]( QVector fixedReads) mutable { - if (!guard) + if (!guard || !shouldContinue()) return; QStringList selectedIds; for (const QString& key : { @@ -531,15 +536,19 @@ void NewPositionRuntime::buildQuoteInputAsync( [guard, requestObject, network, walletOpen, config, pair, selectedIds = std::move(selectedIds), fixedReads = std::move(fixedReads), + shouldContinue, callback = std::move(callback)]( QVector walletReads) mutable { - if (!guard) + if (!guard || !shouldContinue()) return; auto finish = [requestObject, network, walletOpen, config, fixedReads = std::move(fixedReads), walletReads = std::move(walletReads), + shouldContinue, callback = std::move(callback)]( QVector selectedReads) mutable { + if (!shouldContinue()) + return; QHash walletById; for (const WalletAccountRead& read : walletReads) walletById.insert(read.accountId, read); @@ -583,13 +592,23 @@ void NewPositionRuntime::quoteAsync(const QVariantMap& request, const ActiveNetworkSnapshot& network, bool walletOpen, bool forceRefresh, + bool isPoolProbe, ResultCallback callback) { QPointer guard(this); + const quint64 quoteGeneration = isPoolProbe + ? 0 : ++m_userQuoteGeneration; + std::function shouldContinue = [guard, quoteGeneration]() { + return guard + && (quoteGeneration == 0 + || quoteGeneration == guard->m_userQuoteGeneration); + }; buildQuoteInputAsync(request, network, walletOpen, forceRefresh, - [guard, callback = std::move(callback)]( + shouldContinue, + [guard, shouldContinue, + callback = std::move(callback)]( QJsonObject input, QJsonObject error) mutable { - if (!guard) + if (!guard || !shouldContinue()) return; if (!error.isEmpty()) { callback(error.toVariantMap()); @@ -626,11 +645,17 @@ void NewPositionRuntime::submitAsync(const QVariantMap& request, const quint64 walletGeneration = m_walletGeneration; m_submitCallback = std::move(callback); QPointer guard(this); + std::function shouldContinue = + [guard, submitGeneration, walletGeneration]() { + return guard + && guard->submitIsCurrent(submitGeneration, walletGeneration); + }; buildQuoteInputAsync(request, network, true, true, - [guard, quoteHash, submitGeneration, walletGeneration]( + shouldContinue, + [guard, quoteHash, submitGeneration, walletGeneration, + shouldContinue]( QJsonObject input, QJsonObject error) mutable { - if (!guard - || !guard->submitIsCurrent(submitGeneration, walletGeneration)) + if (!guard || !shouldContinue()) return; if (!error.isEmpty()) { guard->finishSubmit(submitGeneration, error.toVariantMap()); diff --git a/apps/amm/src/NewPositionRuntime.h b/apps/amm/src/NewPositionRuntime.h index bd3dacb..4637f08 100644 --- a/apps/amm/src/NewPositionRuntime.h +++ b/apps/amm/src/NewPositionRuntime.h @@ -36,6 +36,7 @@ public: const ActiveNetworkSnapshot& network, bool walletOpen, bool forceRefresh, + bool isPoolProbe, ResultCallback callback); void submitAsync(const QVariantMap& request, const QString& quoteHash, @@ -74,6 +75,7 @@ private: const ActiveNetworkSnapshot& network, bool walletOpen, bool forceRefresh, + std::function shouldContinue, std::function callback); void submitPlanAsync(QJsonObject input, const QString& quoteHash, @@ -104,6 +106,7 @@ private: bool m_submitInFlight = false; quint64 m_walletGeneration = 0; quint64 m_contextGeneration = 0; + quint64 m_userQuoteGeneration = 0; quint64 m_submitGeneration = 0; ResultCallback m_submitCallback; }; diff --git a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp index 1f2953e..c56735e 100644 --- a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp +++ b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp @@ -298,6 +298,7 @@ namespace { AmmClientResult pairIds(const QJsonObject&) const override { + ++pairIdsCalls; return success({ { QStringLiteral("status"), QStringLiteral("ok") }, { QStringLiteral("tokenAId"), QStringLiteral("token-a") }, @@ -320,6 +321,7 @@ namespace { AmmClientResult quote(const QJsonObject&) const override { + ++quoteCalls; return success({ { QStringLiteral("schema"), QStringLiteral("new-position.v2") }, { QStringLiteral("status"), QStringLiteral("ok") }, @@ -385,6 +387,8 @@ namespace { mutable bool sawFreshLp = false; mutable int tokenIdsCalls = 0; mutable int contextCalls = 0; + mutable int pairIdsCalls = 0; + mutable int quoteCalls = 0; mutable int planCalls = 0; mutable int planFailuresRemaining = 0; mutable QStringList freshLpAccountIds; @@ -767,6 +771,71 @@ int main(int argc, char** argv) "superseded context should stop before downstream work")) return 1; + LocalRpcServer staleQuoteServer; + if (!expect(staleQuoteServer.listen(), + "stale-quote sequencer should listen")) + return 1; + staleQuoteServer.holdResponses(); + QTemporaryFile staleQuoteConfig; + if (!expect(staleQuoteConfig.open(), + "stale-quote config should open")) + return 1; + staleQuoteConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), staleQuoteServer.endpoint() }, + }).toJson(QJsonDocument::Compact)); + staleQuoteConfig.flush(); + FakeWallet staleQuoteWallet; + FakeAmmClient staleQuoteClient; + SequencerClient staleQuoteSequencer(&staleQuoteClient); + if (!expect(staleQuoteSequencer.configure(staleQuoteConfig.fileName()), + "stale-quote sequencer should configure")) + return 1; + NewPositionRuntime staleQuoteRuntime( + &staleQuoteWallet, &staleQuoteClient, &staleQuoteSequencer); + int staleQuoteCallbacks = 0; + int latestQuoteCallbacks = 0; + staleQuoteRuntime.quoteAsync( + request, readyNetwork(), true, false, false, + [&](QVariantMap) { ++staleQuoteCallbacks; }); + if (!expect(waitForRequestCount(staleQuoteServer, 1), + "first quote should begin the config read")) + return 1; + staleQuoteRuntime.quoteAsync( + request, readyNetwork(), true, false, false, + [&](QVariantMap) { ++latestQuoteCallbacks; }); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (!expect(staleQuoteServer.requestCount() == 1, + "superseding quote should share the active config read")) + return 1; + staleQuoteServer.releaseNextResponse(); + if (!expect(waitForCondition([&]() { return latestQuoteCallbacks == 1; }), + "latest quote should complete")) + return 1; + if (!expect(staleQuoteCallbacks == 0 + && staleQuoteClient.pairIdsCalls == 1 + && staleQuoteClient.quoteCalls == 1, + "superseded user quote should stop before pair work")) + return 1; + + int poolProbeCallbacks = 0; + int concurrentUserCallbacks = 0; + staleQuoteRuntime.quoteAsync( + request, readyNetwork(), true, false, true, + [&](QVariantMap) { ++poolProbeCallbacks; }); + staleQuoteRuntime.quoteAsync( + request, readyNetwork(), true, false, false, + [&](QVariantMap) { ++concurrentUserCallbacks; }); + if (!expect(waitForCondition([&]() { + return poolProbeCallbacks == 1 + && concurrentUserCallbacks == 1; + }), + "pool probe and user quote should complete independently")) + return 1; + if (!expect(staleQuoteClient.pairIdsCalls == 3 + && staleQuoteClient.quoteCalls == 3, + "user quote cancellation must not cancel the pool probe")) + return 1; + LocalRpcServer server; if (!expect(server.listen(), "local sequencer should listen")) return 1; @@ -828,7 +897,7 @@ int main(int argc, char** argv) refreshClient.normalizedAccountIds.count(selectedAccountHex); QVariantMap selectedQuote; selectedRuntime.quoteAsync( - selectedRequest, readyNetwork(), true, false, + selectedRequest, readyNetwork(), true, false, false, [&](QVariantMap result) { selectedQuote = std::move(result); }); if (!expect(waitForCondition([&]() { return !selectedQuote.isEmpty(); }), "selected-holding quote should complete")) diff --git a/apps/amm/tests/qml/tst_LiquidityPage.qml b/apps/amm/tests/qml/tst_LiquidityPage.qml index 4484d85..57968bd 100644 --- a/apps/amm/tests/qml/tst_LiquidityPage.qml +++ b/apps/amm/tests/qml/tst_LiquidityPage.qml @@ -38,6 +38,7 @@ TestCase { property int contextRefreshCalls: 0 property int contextRequestId: 0 property int quoteCalls: 0 + property var quotePoolProbeFlags: [] property int submitCalls: 0 property var lastContextRefreshRequest: ({}) @@ -49,8 +50,12 @@ TestCase { newPositionSubmitResult = result } - function requestNewPositionQuote(request, requestId, forceRefresh) { + function requestNewPositionQuote(request, requestId, forceRefresh, + isPoolProbe) { ++quoteCalls + quotePoolProbeFlags = quotePoolProbeFlags.concat([ + isPoolProbe === true + ]) var result = JSON.parse(JSON.stringify(quoteResult || ({}))) result.requestId = requestId newPositionQuoteResult = result @@ -218,6 +223,22 @@ TestCase { wait(0) page.flow.pollPendingPool() compare(backend.quoteCalls, 1) + compare(backend.quotePoolProbeFlags.length, 1) + compare(backend.quotePoolProbeFlags[0], true) + } + + function test_userQuoteUsesInteractiveRequestLane() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + page.flow.pendingQuoteRequest = { + "ok": true, + "request": ({}) + } + page.flow.active = true + page.flow.requestQuoteNow(page.flow.quoteSerial) + + verify(backend.quoteCalls > 0) + compare(backend.quotePoolProbeFlags[0], false) } function test_activePoolSubmissionDoesNotStartPoolCreationProbe() {