From 5b3663ef279d04170f0a47f11083e847cd07a4fe Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 20:29:19 -0300 Subject: [PATCH] fix(amm): submit wallet mutations asynchronously --- apps/amm/src/AmmUiBackend.cpp | 3 + apps/amm/src/NewPositionRuntime.cpp | 275 ++++++++++++------ apps/amm/src/NewPositionRuntime.h | 16 +- apps/amm/tests/cpp/NewPositionRuntimeTest.cpp | 162 +++++++++++ 4 files changed, 363 insertions(+), 93 deletions(-) diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index cf55f65..2b8763b 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -279,6 +279,7 @@ void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); const bool walletWasOpen = isWalletOpen(); + const bool walletCouldSubmit = walletCanSubmit(); const bool wasReachable = sequencerReachable(); const QString previousAddress = sequencerAddr(); @@ -300,6 +301,8 @@ void AmmUiBackend::syncWalletState() m_sequencer->configure(state.configPath); const bool addressChanged = previousAddress != state.sequencerAddress; + if ((walletCouldSubmit && !state.canSubmit()) || addressChanged) + m_newPosition->cancelSubmit(); if (addressChanged) { m_identityRetryTimer->stop(); m_pendingTransactions.clear(); diff --git a/apps/amm/src/NewPositionRuntime.cpp b/apps/amm/src/NewPositionRuntime.cpp index 1e18f61..07f039b 100644 --- a/apps/amm/src/NewPositionRuntime.cpp +++ b/apps/amm/src/NewPositionRuntime.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -171,6 +172,7 @@ NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, void NewPositionRuntime::clearWalletAccounts() { ++m_walletGeneration; + cancelSubmit(); m_walletPublicAccountIds.clear(); m_wallet->clearSnapshot(); } @@ -182,11 +184,44 @@ void NewPositionRuntime::setWalletAccounts(const QVector& account if (account.isPublic) publicAccountIds.append(account.address); } - if (publicAccountIds != m_walletPublicAccountIds) + if (publicAccountIds != m_walletPublicAccountIds) { ++m_walletGeneration; + cancelSubmit(); + } m_walletPublicAccountIds = std::move(publicAccountIds); } +void NewPositionRuntime::cancelSubmit() +{ + if (!m_submitInFlight) + return; + ++m_submitGeneration; + m_submitInFlight = false; + ResultCallback callback = std::move(m_submitCallback); + m_submitCallback = {}; + if (callback) + callback(publicError(QStringLiteral("wallet_unavailable")).toVariantMap()); +} + +void NewPositionRuntime::finishSubmit(quint64 submitGeneration, QVariantMap result) +{ + if (!m_submitInFlight || submitGeneration != m_submitGeneration) + return; + m_submitInFlight = false; + ResultCallback callback = std::move(m_submitCallback); + m_submitCallback = {}; + if (callback) + callback(std::move(result)); +} + +bool NewPositionRuntime::submitIsCurrent(quint64 submitGeneration, + quint64 walletGeneration) const +{ + return m_submitInFlight + && submitGeneration == m_submitGeneration + && walletGeneration == m_walletGeneration; +} + QJsonArray NewPositionRuntime::walletAccountReads(bool walletOpen, bool refresh) const { if (!walletOpen) @@ -280,14 +315,20 @@ void NewPositionRuntime::contextAsync(const QVariantMap& request, } const QString configId = configResult.value.value( QStringLiteral("configId")).toString(); + QPointer guard(this); m_sequencer->readAccounts({ configId }, refreshPublicData, - [this, request, network, walletOpen, refreshPublicData, + [guard, request, network, walletOpen, refreshPublicData, callback = std::move(callback)](QVector configReads) mutable { + if (!guard) + return; const QJsonObject config = accountReadJson(configReads.value(0)); - m_sequencer->readAccounts(m_walletPublicAccountIds, refreshPublicData, - [this, request, network, walletOpen, refreshPublicData, config, + guard->m_sequencer->readAccounts( + guard->m_walletPublicAccountIds, refreshPublicData, + [guard, request, network, walletOpen, refreshPublicData, config, callback = std::move(callback)]( QVector walletReads) mutable { + if (!guard) + return; const QJsonObject hints = QJsonObject::fromVariantMap(request); QJsonArray configured; for (const QString& id : network.tokenIds) @@ -297,7 +338,7 @@ void NewPositionRuntime::contextAsync(const QVariantMap& request, const QJsonArray resolved = variantStringArray( hints.value(QStringLiteral("resolvedTokenIds")).toVariant()); const QJsonArray walletAccounts = accountReadsJson(walletReads); - const AmmClientResult tokenResult = m_client->tokenIds(QJsonObject { + const AmmClientResult tokenResult = guard->m_client->tokenIds(QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId }, { QStringLiteral("config"), config }, { QStringLiteral("walletAccounts"), walletAccounts }, @@ -322,12 +363,14 @@ void NewPositionRuntime::contextAsync(const QVariantMap& request, .value(QStringLiteral("tokenIds")).toArray()) { definitionIds.append(id.toString()); } - m_sequencer->readAccounts(definitionIds, refreshPublicData, - [this, network, walletOpen, config, walletAccounts, + guard->m_sequencer->readAccounts(definitionIds, refreshPublicData, + [guard, network, walletOpen, config, walletAccounts, configured, recent, resolved, callback = std::move(callback)]( QVector definitions) mutable { - const AmmClientResult result = m_client->context(QJsonObject { + if (!guard) + return; + const AmmClientResult result = guard->m_client->context(QJsonObject { { QStringLiteral("networkId"), network.id }, { QStringLiteral("networkFingerprint"), network.fingerprint }, { QStringLiteral("ammProgramId"), network.ammProgramId }, @@ -354,7 +397,7 @@ void NewPositionRuntime::buildQuoteInputAsync( const ActiveNetworkSnapshot& network, bool walletOpen, bool forceRefresh, - std::function callback) const + std::function callback) { if (network.status != QStringLiteral("ready")) { callback({}, publicError(network.status)); @@ -372,12 +415,15 @@ void NewPositionRuntime::buildQuoteInputAsync( } const QString configId = configResult.value.value( QStringLiteral("configId")).toString(); + QPointer guard(this); m_sequencer->readAccounts({ configId }, forceRefresh, - [this, request, network, walletOpen, forceRefresh, + [guard, request, network, walletOpen, forceRefresh, callback = std::move(callback)](QVector configReads) mutable { + if (!guard) + return; const QJsonObject config = accountReadJson(configReads.value(0)); const QJsonObject requestObject = QJsonObject::fromVariantMap(request); - const AmmClientResult pairResult = m_client->pairIds(QJsonObject { + const AmmClientResult pairResult = guard->m_client->pairIds(QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId }, { QStringLiteral("config"), config }, { QStringLiteral("tokenAId"), @@ -407,15 +453,20 @@ void NewPositionRuntime::buildQuoteInputAsync( pair.value(QStringLiteral("currentTickId")).toString(), pair.value(QStringLiteral("clockId")).toString(), }; - m_sequencer->readAccounts(fixedIds, forceRefresh, - [this, requestObject, network, walletOpen, config, pair, + guard->m_sequencer->readAccounts(fixedIds, forceRefresh, + [guard, requestObject, network, walletOpen, config, pair, callback = std::move(callback)]( QVector fixedReads) mutable { - m_sequencer->readAccounts(m_walletPublicAccountIds, false, - [this, requestObject, network, walletOpen, config, pair, + if (!guard) + return; + guard->m_sequencer->readAccounts( + guard->m_walletPublicAccountIds, false, + [guard, requestObject, network, walletOpen, config, pair, fixedReads = std::move(fixedReads), callback = std::move(callback)]( QVector walletReads) mutable { + if (!guard) + return; QStringList selectedIds; for (const QString& key : { QStringLiteral("holdingAId"), @@ -426,8 +477,7 @@ void NewPositionRuntime::buildQuoteInputAsync( if (!id.isEmpty() && !selectedIds.contains(id)) selectedIds.append(id); } - auto finish = [this, requestObject, network, walletOpen, - config, pair, + auto finish = [requestObject, network, walletOpen, config, fixedReads = std::move(fixedReads), walletReads = std::move(walletReads), callback = std::move(callback)]( @@ -464,7 +514,7 @@ void NewPositionRuntime::buildQuoteInputAsync( if (selectedIds.isEmpty()) finish({}); else - m_sequencer->readAccounts( + guard->m_sequencer->readAccounts( selectedIds, true, std::move(finish)); }); }); @@ -477,14 +527,17 @@ void NewPositionRuntime::quoteAsync(const QVariantMap& request, bool forceRefresh, ResultCallback callback) { + QPointer guard(this); buildQuoteInputAsync(request, network, walletOpen, forceRefresh, - [this, callback = std::move(callback)]( + [guard, callback = std::move(callback)]( QJsonObject input, QJsonObject error) mutable { + if (!guard) + return; if (!error.isEmpty()) { callback(error.toVariantMap()); return; } - const AmmClientResult result = m_client->quote(input); + const AmmClientResult result = guard->m_client->quote(input); callback((result.ok ? result.value : publicError(QStringLiteral("backend_error"))) .toVariantMap()); @@ -506,114 +559,154 @@ void NewPositionRuntime::submitAsync(const QVariantMap& request, return; } m_submitInFlight = true; + const quint64 submitGeneration = ++m_submitGeneration; const quint64 walletGeneration = m_walletGeneration; - auto finish = [this, callback = std::move(callback)](QVariantMap result) mutable { - m_submitInFlight = false; - callback(std::move(result)); - }; + m_submitCallback = std::move(callback); + QPointer guard(this); buildQuoteInputAsync(request, network, true, true, - [this, quoteHash, walletGeneration, finish = std::move(finish)]( + [guard, quoteHash, submitGeneration, walletGeneration]( QJsonObject input, QJsonObject error) mutable { - if (walletGeneration != m_walletGeneration) { - finish(publicError(QStringLiteral("wallet_unavailable")).toVariantMap()); + if (!guard + || !guard->submitIsCurrent(submitGeneration, walletGeneration)) return; - } if (!error.isEmpty()) { - finish(error.toVariantMap()); + guard->finishSubmit(submitGeneration, error.toVariantMap()); return; } - const AmmClientResult quoteResult = m_client->quote(input); + const AmmClientResult quoteResult = guard->m_client->quote(input); if (!quoteResult.ok) { - finish(publicError(QStringLiteral("backend_error")).toVariantMap()); + guard->finishSubmit( + submitGeneration, + publicError(QStringLiteral("backend_error")).toVariantMap()); return; } const QJsonObject quote = quoteResult.value; if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) { QJsonObject result = publicError(QStringLiteral("quote_changed")); result.insert(QStringLiteral("quote"), quote); - finish(result.toVariantMap()); + guard->finishSubmit(submitGeneration, result.toVariantMap()); return; } if (!quote.value(QStringLiteral("canSubmit")).toBool(false)) { QJsonObject result = publicError(QStringLiteral("quote_not_submittable")); result.insert(QStringLiteral("quote"), quote); - finish(result.toVariantMap()); + guard->finishSubmit(submitGeneration, result.toVariantMap()); return; } - QJsonValue freshLp; if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { - if (walletGeneration != m_walletGeneration) { - finish(publicError(QStringLiteral("wallet_unavailable")).toVariantMap()); - return; - } - const WalletAccountCreation creation = m_wallet->createAccount(true); - if (!creation.ok() || !creation.publicAccount.ok()) { - finish(publicError(QStringLiteral("wallet_submission_failed")).toVariantMap()); - return; - } - freshLp = accountReadJson(creation.publicAccount); - if (!m_walletPublicAccountIds.contains(creation.accountId)) - m_walletPublicAccountIds.append(creation.accountId); + guard->m_wallet->createAccountAsync( + true, + [guard, input = std::move(input), quoteHash, + submitGeneration, walletGeneration]( + WalletAccountCreation creation) mutable { + if (!guard + || !guard->submitIsCurrent( + submitGeneration, walletGeneration)) + return; + if (!creation.ok() || !creation.publicAccount.ok()) { + const QString code = creation.failure + == WalletFailure::WalletUnavailable + ? QStringLiteral("wallet_unavailable") + : QStringLiteral("wallet_submission_failed"); + guard->finishSubmit( + submitGeneration, publicError(code).toVariantMap()); + return; + } + if (!guard->m_walletPublicAccountIds.contains(creation.accountId)) + guard->m_walletPublicAccountIds.append(creation.accountId); + guard->submitPlanAsync( + std::move(input), quoteHash, + accountReadJson(creation.publicAccount), + submitGeneration, walletGeneration); + }); + return; } + guard->submitPlanAsync( + std::move(input), quoteHash, {}, + submitGeneration, walletGeneration); + }); +} - QJsonObject planInput = input; - planInput.insert(QStringLiteral("quoteHash"), quoteHash); - planInput.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch()); - if (!freshLp.isUndefined()) - planInput.insert(QStringLiteral("freshLp"), freshLp); - const AmmClientResult planResult = m_client->plan(planInput); - if (!planResult.ok) { - finish(publicError(QStringLiteral("backend_error")).toVariantMap()); - return; - } - const QJsonObject plan = planResult.value; - if (plan.value(QStringLiteral("status")).toString() - != QStringLiteral("ready")) { - const QString code = plan.value(QStringLiteral("code")).toString(); - finish(publicError(code.isEmpty() - ? QStringLiteral("wallet_submission_failed") : code).toVariantMap()); - return; - } +void NewPositionRuntime::submitPlanAsync(QJsonObject input, + const QString& quoteHash, + QJsonValue freshLp, + quint64 submitGeneration, + quint64 walletGeneration) +{ + if (!submitIsCurrent(submitGeneration, walletGeneration)) + return; - const QStringList accountIds = jsonStringList( - plan.value(QStringLiteral("accountIds")).toArray()); - const QVector signingRequirements = jsonBoolList( - plan.value(QStringLiteral("signingRequirements")).toArray()); - const QVector instruction = jsonUIntList( - plan.value(QStringLiteral("instruction")).toArray()); - bool deadlineValid = false; - const qulonglong deadline = plan.value(QStringLiteral("deadlineMs")) - .toString().toULongLong(&deadlineValid); - if (!deadlineValid - || static_cast(QDateTime::currentMSecsSinceEpoch()) >= deadline) { - finish(publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap()); + input.insert(QStringLiteral("quoteHash"), quoteHash); + input.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch()); + if (!freshLp.isUndefined() && !freshLp.isNull()) + input.insert(QStringLiteral("freshLp"), std::move(freshLp)); + const AmmClientResult planResult = m_client->plan(input); + if (!planResult.ok) { + finishSubmit( + submitGeneration, + publicError(QStringLiteral("backend_error")).toVariantMap()); + return; + } + const QJsonObject plan = planResult.value; + if (plan.value(QStringLiteral("status")).toString() + != QStringLiteral("ready")) { + const QString code = plan.value(QStringLiteral("code")).toString(); + finishSubmit( + submitGeneration, + publicError(code.isEmpty() + ? QStringLiteral("wallet_submission_failed") : code).toVariantMap()); + return; + } + + bool deadlineValid = false; + const qulonglong deadline = plan.value(QStringLiteral("deadlineMs")) + .toString().toULongLong(&deadlineValid); + if (!deadlineValid + || static_cast(QDateTime::currentMSecsSinceEpoch()) >= deadline) { + finishSubmit( + submitGeneration, + publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap()); + return; + } + if (!submitIsCurrent(submitGeneration, walletGeneration)) + return; + + WalletTransaction transaction { + plan.value(QStringLiteral("programId")).toString(), + jsonStringList(plan.value(QStringLiteral("accountIds")).toArray()), + jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray()), + jsonUIntList(plan.value(QStringLiteral("instruction")).toArray()), + }; + QPointer guard(this); + m_wallet->submitPublicTransactionAsync( + transaction, + [guard, submitGeneration, walletGeneration, deadlineMs = + plan.value(QStringLiteral("deadlineMs")), affectedAccountIds = + plan.value(QStringLiteral("affectedAccountIds"))]( + WalletSubmission submission) mutable { + if (!guard + || !guard->submitIsCurrent(submitGeneration, walletGeneration)) return; - } - if (walletGeneration != m_walletGeneration) { - finish(publicError(QStringLiteral("wallet_unavailable")).toVariantMap()); - return; - } - const WalletSubmission submission = m_wallet->submitPublicTransaction({ - plan.value(QStringLiteral("programId")).toString(), - accountIds, - signingRequirements, - instruction, - }); const QString transactionId = submission.accepted() ? base58TransactionId(submission.nativeHash) : QString(); if (transactionId.isEmpty()) { - finish(publicError(QStringLiteral("wallet_submission_failed")).toVariantMap()); + const QString code = submission.failure + == WalletFailure::WalletUnavailable + ? QStringLiteral("wallet_unavailable") + : QStringLiteral("wallet_submission_failed"); + guard->finishSubmit( + submitGeneration, publicError(code).toVariantMap()); return; } - finish(QJsonObject { + guard->finishSubmit(submitGeneration, QJsonObject { { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, { QStringLiteral("status"), QStringLiteral("submitted") }, { QStringLiteral("transactionId"), transactionId }, { QStringLiteral("nativeTransactionHash"), submission.nativeHash }, - { QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) }, + { QStringLiteral("deadlineMs"), deadlineMs }, { QStringLiteral("affectedAccountIds"), - plan.value(QStringLiteral("affectedAccountIds")) }, + affectedAccountIds }, }.toVariantMap()); }); } diff --git a/apps/amm/src/NewPositionRuntime.h b/apps/amm/src/NewPositionRuntime.h index f09289a..8f1d700 100644 --- a/apps/amm/src/NewPositionRuntime.h +++ b/apps/amm/src/NewPositionRuntime.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -15,7 +16,7 @@ class WalletProvider; class SequencerClient; struct WalletAccount; -class NewPositionRuntime { +class NewPositionRuntime : public QObject { public: using ResultCallback = std::function; @@ -41,6 +42,7 @@ public: const ActiveNetworkSnapshot& network, bool walletCanSubmit, ResultCallback callback); + void cancelSubmit(); QVariantMap context(const QVariantMap& request, const ActiveNetworkSnapshot& network, @@ -65,7 +67,15 @@ private: const ActiveNetworkSnapshot& network, bool walletOpen, bool forceRefresh, - std::function callback) const; + std::function callback); + void submitPlanAsync(QJsonObject input, + const QString& quoteHash, + QJsonValue freshLp, + quint64 submitGeneration, + quint64 walletGeneration); + void finishSubmit(quint64 submitGeneration, QVariantMap result); + bool submitIsCurrent(quint64 submitGeneration, + quint64 walletGeneration) const; WalletProvider* m_wallet; AmmClient* m_client; @@ -73,4 +83,6 @@ private: QStringList m_walletPublicAccountIds; bool m_submitInFlight = false; quint64 m_walletGeneration = 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 584d17c..c404779 100644 --- a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp +++ b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp @@ -15,6 +15,8 @@ #include #include +#include +#include #include namespace { @@ -143,6 +145,15 @@ namespace { return first && second; } + bool waitForCondition(const std::function& condition) + { + QElapsedTimer timer; + timer.start(); + while (!condition() && timer.elapsed() < 3000) + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + return condition(); + } + class FakeWallet final : public WalletProvider { public: WalletSession connect(const WalletPaths&) override @@ -182,6 +193,17 @@ namespace { return creation; } + void createAccountAsync(bool isPublic, AccountCreationCallback callback) override + { + WalletAccountCreation creation = createAccount(isPublic); + if (deferAccountCreation) { + pendingAccountCreation = std::move(callback); + pendingCreation = std::move(creation); + } else { + callback(std::move(creation)); + } + } + WalletAccountRead readPublicAccount(const QString& accountId) const override { WalletAccountRead read; @@ -197,6 +219,32 @@ namespace { return { WalletFailure::None, transactionHash }; } + void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) override + { + WalletSubmission submission = submitPublicTransaction(transaction); + if (deferSubmission) { + pendingSubmissionCallback = std::move(callback); + pendingSubmission = std::move(submission); + } else { + callback(std::move(submission)); + } + } + + void finishAccountCreation() + { + AccountCreationCallback callback = std::move(pendingAccountCreation); + if (callback) + callback(std::move(pendingCreation)); + } + + void finishSubmission() + { + SubmissionCallback callback = std::move(pendingSubmissionCallback); + if (callback) + callback(std::move(pendingSubmission)); + } + void disconnect() override {} QString transactionHash = QStringLiteral( @@ -205,6 +253,12 @@ namespace { int createdAccounts = 0; int submissions = 0; WalletTransaction submitted; + bool deferAccountCreation = false; + bool deferSubmission = false; + AccountCreationCallback pendingAccountCreation; + SubmissionCallback pendingSubmissionCallback; + WalletAccountCreation pendingCreation; + WalletSubmission pendingSubmission; }; class FakeAmmClient final : public AmmClient { @@ -625,5 +679,113 @@ int main(int argc, char** argv) "wallet change should cancel submit before side effects")) return 1; + LocalRpcServer asyncSubmitServer; + if (!expect(asyncSubmitServer.listen(), "async-submit sequencer should listen")) + return 1; + if (!expect(sequencerConfig.resize(0) && sequencerConfig.seek(0), + "async-submit config should rewind")) + return 1; + sequencerConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), asyncSubmitServer.endpoint() }, + }).toJson(QJsonDocument::Compact)); + sequencerConfig.flush(); + if (!expect(sequencer.configure(sequencerConfig.fileName()), + "async-submit sequencer should configure")) + return 1; + + FakeWallet asyncWallet; + asyncWallet.deferAccountCreation = true; + asyncWallet.deferSubmission = true; + FakeAmmClient asyncClient; + NewPositionRuntime asyncRuntime(&asyncWallet, &asyncClient, &sequencer); + int asyncCallbackCount = 0; + QVariantMap asyncResult; + asyncRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + ++asyncCallbackCount; + asyncResult = std::move(result); + }); + if (!expect(waitForCondition([&]() { return asyncWallet.createdAccounts == 1; }), + "submit should asynchronously request a fresh account")) + return 1; + if (!expect(asyncCallbackCount == 0 && asyncWallet.submissions == 0, + "account creation should not block or finish submission")) + return 1; + + QVariantMap concurrentResult; + asyncRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { concurrentResult = std::move(result); }); + if (!expect(concurrentResult.value(QStringLiteral("code")).toString() + == QStringLiteral("submit_in_progress"), + "concurrent submit should fail while async mutation is pending")) + return 1; + + asyncWallet.finishAccountCreation(); + if (!expect(waitForCondition([&]() { return asyncWallet.submissions == 1; }), + "fresh account completion should dispatch transaction")) + return 1; + if (!expect(asyncCallbackCount == 0, + "transaction submission should remain asynchronous")) + return 1; + asyncWallet.finishSubmission(); + if (!expect(asyncCallbackCount == 1 + && asyncResult.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted"), + "async wallet completion should finish exactly once")) + return 1; + + FakeWallet cancelledMutationWallet; + cancelledMutationWallet.deferAccountCreation = true; + FakeAmmClient cancelledMutationClient; + NewPositionRuntime cancelledMutationRuntime( + &cancelledMutationWallet, &cancelledMutationClient, &sequencer); + int cancelledMutationCallbacks = 0; + QVariantMap cancelledMutationResult; + cancelledMutationRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + ++cancelledMutationCallbacks; + cancelledMutationResult = std::move(result); + }); + if (!expect(waitForCondition( + [&]() { return cancelledMutationWallet.createdAccounts == 1; }), + "cancellable submit should reach account creation")) + return 1; + cancelledMutationRuntime.clearWalletAccounts(); + if (!expect(cancelledMutationCallbacks == 1 + && cancelledMutationResult.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable"), + "wallet change should immediately cancel pending mutation")) + return 1; + cancelledMutationWallet.finishAccountCreation(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (!expect(cancelledMutationCallbacks == 1 + && cancelledMutationWallet.submissions == 0, + "stale account completion should have no transaction side effect")) + return 1; + + FakeWallet destroyedRuntimeWallet; + destroyedRuntimeWallet.deferSubmission = true; + FakeAmmClient destroyedRuntimeClient; + destroyedRuntimeClient.requiresFreshLp = false; + int destroyedRuntimeCallbacks = 0; + auto destroyedRuntime = std::make_unique( + &destroyedRuntimeWallet, &destroyedRuntimeClient, &sequencer); + destroyedRuntime->submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap) { ++destroyedRuntimeCallbacks; }); + if (!expect(waitForCondition( + [&]() { return destroyedRuntimeWallet.submissions == 1; }), + "lifetime test should reach async submission")) + return 1; + destroyedRuntime.reset(); + destroyedRuntimeWallet.finishSubmission(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (!expect(destroyedRuntimeCallbacks == 0, + "destroyed runtime should ignore late wallet completion")) + return 1; + return 0; }