diff --git a/apps/amm/src/NewPositionRuntime.cpp b/apps/amm/src/NewPositionRuntime.cpp index 07f039b..0e8985f 100644 --- a/apps/amm/src/NewPositionRuntime.cpp +++ b/apps/amm/src/NewPositionRuntime.cpp @@ -19,6 +19,7 @@ namespace { const char SCHEMA[] = "new-position.v2"; constexpr qsizetype HASH_BYTES = 32; + constexpr qsizetype ACCOUNT_ID_HEX_SIZE = HASH_BYTES * 2; constexpr std::size_t BASE58_BUFFER_SIZE = 45; QString base58TransactionId(const QString& transactionHash) @@ -53,6 +54,32 @@ namespace { static_cast(bytes.size())).toHex()); } + bool isLowerHex(const QString& value, qsizetype size) + { + if (value.size() != size) + return false; + for (qsizetype index = 0; index < value.size(); ++index) { + const char16_t character = value.at(index).unicode(); + if (!((character >= u'0' && character <= u'9') + || (character >= u'a' && character <= u'f'))) { + return false; + } + } + return true; + } + + bool isDefaultAccountRead(const WalletAccountRead& read, + const QString& expectedAccountId) + { + return read.ok() + && read.accountId == expectedAccountId + && isLowerHex(read.accountId, ACCOUNT_ID_HEX_SIZE) + && read.programOwner == QString(ACCOUNT_ID_HEX_SIZE, QLatin1Char('0')) + && read.balanceHex == QString(32, QLatin1Char('0')) + && read.nonceHex == QString(32, QLatin1Char('0')) + && read.dataHex.isEmpty(); + } + QJsonObject issue(const QString& code, const QJsonArray& blockingFields = {}) { @@ -172,9 +199,10 @@ NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, void NewPositionRuntime::clearWalletAccounts() { ++m_walletGeneration; - cancelSubmit(); m_walletPublicAccountIds.clear(); + clearPendingFreshLp(); m_wallet->clearSnapshot(); + cancelSubmit(); } void NewPositionRuntime::setWalletAccounts(const QVector& accounts) @@ -184,11 +212,33 @@ void NewPositionRuntime::setWalletAccounts(const QVector& account if (account.isPublic) publicAccountIds.append(account.address); } + publicAccountIds.sort(Qt::CaseSensitive); if (publicAccountIds != m_walletPublicAccountIds) { - ++m_walletGeneration; + if (!m_pendingFreshLpAccountId.isEmpty() + && !publicAccountIds.contains(m_pendingFreshLpAccountId) + && m_freshLpState == FreshLpState::Ready) { + clearPendingFreshLp(); + } + m_walletPublicAccountIds = std::move(publicAccountIds); cancelSubmit(); + return; } - m_walletPublicAccountIds = std::move(publicAccountIds); +} + +void NewPositionRuntime::rememberPendingFreshLp(const QString& accountId) +{ + m_pendingFreshLpAccountId = accountId; + m_freshLpState = FreshLpState::Ready; + if (!m_walletPublicAccountIds.contains(accountId)) { + m_walletPublicAccountIds.append(accountId); + m_walletPublicAccountIds.sort(Qt::CaseSensitive); + } +} + +void NewPositionRuntime::clearPendingFreshLp() +{ + m_pendingFreshLpAccountId.clear(); + m_freshLpState = FreshLpState::None; } void NewPositionRuntime::cancelSubmit() @@ -558,6 +608,11 @@ void NewPositionRuntime::submitAsync(const QVariantMap& request, callback(publicError(QStringLiteral("wallet_syncing")).toVariantMap()); return; } + if (m_freshLpState == FreshLpState::Creating + || m_freshLpState == FreshLpState::Submitting) { + callback(publicError(QStringLiteral("submit_in_progress")).toVariantMap()); + return; + } m_submitInFlight = true; const quint64 submitGeneration = ++m_submitGeneration; const quint64 walletGeneration = m_walletGeneration; @@ -595,35 +650,132 @@ void NewPositionRuntime::submitAsync(const QVariantMap& request, } if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { - 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); - }); + guard->prepareFreshLpAsync( + std::move(input), quoteHash, + submitGeneration, walletGeneration); return; } guard->submitPlanAsync( - std::move(input), quoteHash, {}, + std::move(input), quoteHash, {}, {}, + submitGeneration, walletGeneration); + }); +} + +void NewPositionRuntime::prepareFreshLpAsync(QJsonObject input, + const QString& quoteHash, + quint64 submitGeneration, + quint64 walletGeneration) +{ + if (!submitIsCurrent(submitGeneration, walletGeneration)) + return; + if (m_freshLpState == FreshLpState::Ready + && !m_pendingFreshLpAccountId.isEmpty()) { + validatePendingFreshLpAsync( + std::move(input), quoteHash, + submitGeneration, walletGeneration); + return; + } + if (m_freshLpState != FreshLpState::None) { + finishSubmit( + submitGeneration, + publicError(QStringLiteral("submit_in_progress")).toVariantMap()); + return; + } + + m_freshLpState = FreshLpState::Creating; + QPointer guard(this); + m_wallet->createAccountAsync( + true, + [guard, input = std::move(input), quoteHash, + submitGeneration, walletGeneration]( + WalletAccountCreation creation) mutable { + if (!guard || walletGeneration != guard->m_walletGeneration) + return; + + const bool reusable = creation.ok() + && isLowerHex(creation.accountId, ACCOUNT_ID_HEX_SIZE); + if (reusable) + guard->rememberPendingFreshLp(creation.accountId); + else + guard->clearPendingFreshLp(); + if (!guard->submitIsCurrent(submitGeneration, walletGeneration)) + return; + if (!reusable) { + const QString code = creation.failure + == WalletFailure::WalletUnavailable + ? QStringLiteral("wallet_unavailable") + : QStringLiteral("wallet_submission_failed"); + guard->finishSubmit( + submitGeneration, publicError(code).toVariantMap()); + return; + } + + WalletAccountRead read = std::move(creation.publicAccount); + read.accountId = creation.accountId; + if (isDefaultAccountRead(read, creation.accountId)) { + guard->submitPlanAsync( + std::move(input), quoteHash, accountReadJson(read), + creation.accountId, submitGeneration, walletGeneration); + return; + } + guard->validatePendingFreshLpAsync( + std::move(input), quoteHash, + submitGeneration, walletGeneration); + }); +} + +void NewPositionRuntime::validatePendingFreshLpAsync( + QJsonObject input, + const QString& quoteHash, + quint64 submitGeneration, + quint64 walletGeneration) +{ + if (!submitIsCurrent(submitGeneration, walletGeneration)) + return; + const QString accountId = m_pendingFreshLpAccountId; + if (m_freshLpState != FreshLpState::Ready + || accountId.isEmpty() + || !m_sequencer + || !m_sequencer->isConfigured()) { + finishSubmit( + submitGeneration, + publicError(QStringLiteral("account_read_failed")).toVariantMap()); + return; + } + + QPointer guard(this); + m_sequencer->readAccounts( + { accountId }, true, + [guard, input = std::move(input), quoteHash, accountId, + submitGeneration, walletGeneration]( + QVector reads) mutable { + if (!guard + || !guard->submitIsCurrent( + submitGeneration, walletGeneration)) { + return; + } + if (guard->m_pendingFreshLpAccountId != accountId) { + guard->finishSubmit( + submitGeneration, + publicError(QStringLiteral("wallet_unavailable")).toVariantMap()); + return; + } + const WalletAccountRead read = reads.value(0); + if (!read.ok()) { + guard->finishSubmit( + submitGeneration, + publicError(QStringLiteral("account_read_failed")).toVariantMap()); + return; + } + if (!isDefaultAccountRead(read, accountId)) { + guard->clearPendingFreshLp(); + guard->finishSubmit( + submitGeneration, + publicError(QStringLiteral("submission_status_unknown")).toVariantMap()); + return; + } + guard->submitPlanAsync( + std::move(input), quoteHash, accountReadJson(read), accountId, submitGeneration, walletGeneration); }); } @@ -631,6 +783,7 @@ void NewPositionRuntime::submitAsync(const QVariantMap& request, void NewPositionRuntime::submitPlanAsync(QJsonObject input, const QString& quoteHash, QJsonValue freshLp, + QString freshLpAccountId, quint64 submitGeneration, quint64 walletGeneration) { @@ -678,18 +831,32 @@ void NewPositionRuntime::submitPlanAsync(QJsonObject input, jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray()), jsonUIntList(plan.value(QStringLiteral("instruction")).toArray()), }; + if (!freshLpAccountId.isEmpty() + && freshLpAccountId == m_pendingFreshLpAccountId) { + m_freshLpState = FreshLpState::Submitting; + } QPointer guard(this); m_wallet->submitPublicTransactionAsync( transaction, - [guard, submitGeneration, walletGeneration, deadlineMs = + [guard, submitGeneration, walletGeneration, + freshLpAccountId = std::move(freshLpAccountId), deadlineMs = plan.value(QStringLiteral("deadlineMs")), affectedAccountIds = plan.value(QStringLiteral("affectedAccountIds"))]( WalletSubmission submission) mutable { - if (!guard - || !guard->submitIsCurrent(submitGeneration, walletGeneration)) + if (!guard) return; const QString transactionId = submission.accepted() ? base58TransactionId(submission.nativeHash) : QString(); + if (walletGeneration == guard->m_walletGeneration + && !freshLpAccountId.isEmpty() + && freshLpAccountId == guard->m_pendingFreshLpAccountId) { + if (submission.accepted()) + guard->clearPendingFreshLp(); + else + guard->m_freshLpState = FreshLpState::Ready; + } + if (!guard->submitIsCurrent(submitGeneration, walletGeneration)) + return; if (transactionId.isEmpty()) { const QString code = submission.failure == WalletFailure::WalletUnavailable @@ -803,6 +970,10 @@ QVariantMap NewPositionRuntime::submit(const QVariantMap& request, return publicError(QStringLiteral("submit_in_progress")).toVariantMap(); if (!walletOpen) return publicError(QStringLiteral("wallet_unavailable")).toVariantMap(); + if (m_freshLpState == FreshLpState::Creating + || m_freshLpState == FreshLpState::Submitting) { + return publicError(QStringLiteral("submit_in_progress")).toVariantMap(); + } QScopedValueRollback submitGuard(m_submitInFlight, true); QJsonObject error; @@ -826,11 +997,34 @@ QVariantMap NewPositionRuntime::submit(const QVariantMap& request, } QJsonValue freshLp; + QString freshLpAccountId; if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { - const WalletAccountCreation creation = m_wallet->createAccount(true); - if (!creation.ok() || !creation.publicAccount.ok()) - return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); - freshLp = accountReadJson(creation.publicAccount); + WalletAccountRead read; + if (m_freshLpState == FreshLpState::None) { + m_freshLpState = FreshLpState::Creating; + const WalletAccountCreation creation = m_wallet->createAccount(true); + if (!creation.ok() + || !isLowerHex(creation.accountId, ACCOUNT_ID_HEX_SIZE)) { + clearPendingFreshLp(); + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + } + rememberPendingFreshLp(creation.accountId); + read = creation.publicAccount; + read.accountId = creation.accountId; + } else if (m_freshLpState == FreshLpState::Ready + && !m_pendingFreshLpAccountId.isEmpty()) { + read = m_wallet->readPublicAccount(m_pendingFreshLpAccountId); + } else { + return publicError(QStringLiteral("submit_in_progress")).toVariantMap(); + } + freshLpAccountId = m_pendingFreshLpAccountId; + if (!read.ok()) + return publicError(QStringLiteral("account_read_failed")).toVariantMap(); + if (!isDefaultAccountRead(read, freshLpAccountId)) { + clearPendingFreshLp(); + return publicError(QStringLiteral("submission_status_unknown")).toVariantMap(); + } + freshLp = accountReadJson(read); } QJsonObject planInput = input; @@ -859,14 +1053,27 @@ QVariantMap NewPositionRuntime::submit(const QVariantMap& request, || static_cast(QDateTime::currentMSecsSinceEpoch()) >= deadline) { return publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap(); } + if (!freshLpAccountId.isEmpty() + && freshLpAccountId == m_pendingFreshLpAccountId) { + m_freshLpState = FreshLpState::Submitting; + } const WalletSubmission submission = m_wallet->submitPublicTransaction({ programId, accountIds, signingRequirements, instruction, }); - if (!submission.accepted()) + if (!submission.accepted()) { + if (!freshLpAccountId.isEmpty() + && freshLpAccountId == m_pendingFreshLpAccountId) { + m_freshLpState = FreshLpState::Ready; + } return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + } + if (!freshLpAccountId.isEmpty() + && freshLpAccountId == m_pendingFreshLpAccountId) { + clearPendingFreshLp(); + } const QString transactionId = base58TransactionId(submission.nativeHash); if (transactionId.isEmpty()) return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); diff --git a/apps/amm/src/NewPositionRuntime.h b/apps/amm/src/NewPositionRuntime.h index 8f1d700..6607e6e 100644 --- a/apps/amm/src/NewPositionRuntime.h +++ b/apps/amm/src/NewPositionRuntime.h @@ -57,6 +57,13 @@ public: bool walletOpen); private: + enum class FreshLpState { + None, + Creating, + Ready, + Submitting, + }; + QJsonArray walletAccountReads(bool walletOpen, bool refresh) const; QJsonObject buildQuoteInput(const QVariantMap& request, const ActiveNetworkSnapshot& network, @@ -71,8 +78,19 @@ private: void submitPlanAsync(QJsonObject input, const QString& quoteHash, QJsonValue freshLp, + QString freshLpAccountId, quint64 submitGeneration, quint64 walletGeneration); + void prepareFreshLpAsync(QJsonObject input, + const QString& quoteHash, + quint64 submitGeneration, + quint64 walletGeneration); + void validatePendingFreshLpAsync(QJsonObject input, + const QString& quoteHash, + quint64 submitGeneration, + quint64 walletGeneration); + void rememberPendingFreshLp(const QString& accountId); + void clearPendingFreshLp(); void finishSubmit(quint64 submitGeneration, QVariantMap result); bool submitIsCurrent(quint64 submitGeneration, quint64 walletGeneration) const; @@ -81,6 +99,8 @@ private: AmmClient* m_client; SequencerClient* m_sequencer; QStringList m_walletPublicAccountIds; + QString m_pendingFreshLpAccountId; + FreshLpState m_freshLpState = FreshLpState::None; bool m_submitInFlight = false; quint64 m_walletGeneration = 0; quint64 m_submitGeneration = 0; diff --git a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp index ec03d94..b9c7d73 100644 --- a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp +++ b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp @@ -193,9 +193,13 @@ namespace { { ++createdAccounts; WalletAccountCreation creation; - creation.accountId = QStringLiteral("fresh-lp"); - if (isPublic) + const QLatin1Char accountDigit( + "abcdef"[(createdAccounts - 1) % 6]); + creation.accountId = QString(64, accountDigit); + if (isPublic && !failCreatedPublicRead) creation.publicAccount = readPublicAccount(creation.accountId); + else + creation.publicAccount.accountId = creation.accountId; return creation; } @@ -215,6 +219,9 @@ namespace { WalletAccountRead read; read.accountId = accountId; read.status = QStringLiteral("ok"); + read.programOwner = QString(64, QLatin1Char('0')); + read.balanceHex = readBalanceHex; + read.nonceHex = QString(32, QLatin1Char('0')); return read; } @@ -222,6 +229,10 @@ namespace { { ++submissions; submitted = transaction; + if (submissionFailuresRemaining > 0) { + --submissionFailuresRemaining; + return { WalletFailure::SubmissionFailed, {} }; + } return { WalletFailure::None, transactionHash }; } @@ -261,6 +272,9 @@ namespace { WalletTransaction submitted; bool deferAccountCreation = false; bool deferSubmission = false; + bool failCreatedPublicRead = false; + int submissionFailuresRemaining = 0; + QString readBalanceHex = QString(32, QLatin1Char('0')); AccountCreationCallback pendingAccountCreation; SubmissionCallback pendingSubmissionCallback; WalletAccountCreation pendingCreation; @@ -316,6 +330,16 @@ namespace { AmmClientResult plan(const QJsonObject& request) const override { sawFreshLp = request.contains(QStringLiteral("freshLp")); + if (sawFreshLp) { + freshLpAccountIds.append( + request.value(QStringLiteral("freshLp")).toObject() + .value(QStringLiteral("id")).toString()); + } + ++planCalls; + if (planFailuresRemaining > 0) { + --planFailuresRemaining; + return { false, {} }; + } return success({ { QStringLiteral("status"), QStringLiteral("ready") }, { QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } }, @@ -331,11 +355,21 @@ namespace { AmmClientResult normalizeAccountRpc(const QJsonObject& request) const override { + normalizedAccountIds.append( + request.value(QStringLiteral("accountId")).toString()); return success({ { QStringLiteral("id"), request.value(QStringLiteral("accountId")).toString() }, { QStringLiteral("status"), QStringLiteral("ok") }, - { QStringLiteral("account"), QJsonObject() }, + { QStringLiteral("account"), QJsonObject { + { QStringLiteral("program_owner"), + QString(64, QLatin1Char('0')) }, + { QStringLiteral("balance"), + normalizedBalanceHex }, + { QStringLiteral("nonce"), + QString(32, QLatin1Char('0')) }, + { QStringLiteral("data"), QString() }, + } }, }); } @@ -347,6 +381,11 @@ namespace { QString quoteHash = QStringLiteral("sha256:expected"); bool requiresFreshLp = true; mutable bool sawFreshLp = false; + mutable int planCalls = 0; + mutable int planFailuresRemaining = 0; + mutable QStringList freshLpAccountIds; + mutable QStringList normalizedAccountIds; + QString normalizedBalanceHex = QString(32, QLatin1Char('0')); }; ActiveNetworkSnapshot readyNetwork() @@ -442,6 +481,125 @@ int main(int argc, char** argv) "runtime should dispatch the unchanged plan once")) return 1; + FakeWallet planRetryWallet; + FakeAmmClient planRetryClient; + planRetryClient.planFailuresRemaining = 1; + NewPositionRuntime planRetryRuntime(&planRetryWallet, &planRetryClient); + const QVariantMap failedPlan = planRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + const QVariantMap retriedPlan = planRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(failedPlan.value(QStringLiteral("code")).toString() + == QStringLiteral("backend_error") + && retriedPlan.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted"), + "plan failure should remain retryable")) + return 1; + if (!expect(planRetryWallet.createdAccounts == 1 + && planRetryClient.freshLpAccountIds.size() == 2 + && planRetryClient.freshLpAccountIds.constFirst() + == planRetryClient.freshLpAccountIds.constLast(), + "plan retry should reuse the pending LP account")) + return 1; + + FakeWallet submissionRetryWallet; + submissionRetryWallet.submissionFailuresRemaining = 1; + FakeAmmClient submissionRetryClient; + NewPositionRuntime submissionRetryRuntime( + &submissionRetryWallet, &submissionRetryClient); + const QVariantMap failedSubmission = submissionRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + const QVariantMap retriedSubmission = submissionRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(failedSubmission.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_submission_failed") + && retriedSubmission.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted"), + "wallet rejection should remain retryable")) + return 1; + if (!expect(submissionRetryWallet.createdAccounts == 1 + && submissionRetryWallet.submissions == 2 + && submissionRetryClient.freshLpAccountIds.size() == 2 + && submissionRetryClient.freshLpAccountIds.constFirst() + == submissionRetryClient.freshLpAccountIds.constLast(), + "wallet retry should reuse the pending LP account")) + return 1; + + FakeWallet consumedWallet; + FakeAmmClient consumedClient; + NewPositionRuntime consumedRuntime(&consumedWallet, &consumedClient); + const QVariantMap firstConsumed = consumedRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + const QVariantMap secondConsumed = consumedRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(firstConsumed.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && secondConsumed.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && consumedWallet.createdAccounts == 2 + && consumedClient.freshLpAccountIds.size() == 2 + && consumedClient.freshLpAccountIds.constFirst() + != consumedClient.freshLpAccountIds.constLast(), + "accepted submission should consume the LP account reservation")) + return 1; + + FakeWallet changedWallet; + FakeAmmClient changedClient; + changedClient.planFailuresRemaining = 1; + NewPositionRuntime changedRuntime(&changedWallet, &changedClient); + const QVariantMap beforeWalletChange = changedRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + changedRuntime.clearWalletAccounts(); + const QVariantMap afterWalletChange = changedRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(beforeWalletChange.value(QStringLiteral("code")).toString() + == QStringLiteral("backend_error") + && afterWalletChange.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && changedWallet.createdAccounts == 2, + "wallet change should discard the previous LP reservation")) + return 1; + + FakeWallet readRetryWallet; + readRetryWallet.failCreatedPublicRead = true; + FakeAmmClient readRetryClient; + NewPositionRuntime readRetryRuntime(&readRetryWallet, &readRetryClient); + const QVariantMap failedCreatedRead = readRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + readRetryWallet.failCreatedPublicRead = false; + const QVariantMap retriedCreatedRead = readRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(failedCreatedRead.value(QStringLiteral("code")).toString() + == QStringLiteral("account_read_failed") + && retriedCreatedRead.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && readRetryWallet.createdAccounts == 1, + "created-account read failure should preserve the LP reservation")) + return 1; + + FakeWallet nonDefaultWallet; + FakeAmmClient nonDefaultClient; + nonDefaultClient.planFailuresRemaining = 1; + NewPositionRuntime nonDefaultRuntime(&nonDefaultWallet, &nonDefaultClient); + const QVariantMap reservedAccount = nonDefaultRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + nonDefaultWallet.readBalanceHex = QString(31, QLatin1Char('0')) + + QLatin1Char('1'); + const QVariantMap nonDefaultAccount = nonDefaultRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + nonDefaultWallet.readBalanceHex = QString(32, QLatin1Char('0')); + const QVariantMap replacementAccount = nonDefaultRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(reservedAccount.value(QStringLiteral("code")).toString() + == QStringLiteral("backend_error") + && nonDefaultAccount.value(QStringLiteral("code")).toString() + == QStringLiteral("submission_status_unknown") + && replacementAccount.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && nonDefaultWallet.createdAccounts == 2, + "non-default LP reservation should be replaced")) + return 1; + FakeWallet orderedBytesWallet; orderedBytesWallet.transactionHash = QStringLiteral( "0102030405060708090a0b0c0d0e0f10" @@ -800,6 +958,234 @@ int main(int argc, char** argv) "async wallet completion should finish exactly once")) return 1; + FakeWallet cancelledCreationWallet; + cancelledCreationWallet.deferAccountCreation = true; + FakeAmmClient cancelledCreationClient; + NewPositionRuntime cancelledCreationRuntime( + &cancelledCreationWallet, &cancelledCreationClient, &sequencer); + QVariantMap cancelledCreationResult; + cancelledCreationRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + cancelledCreationResult = std::move(result); + }); + if (!expect(waitForCondition( + [&]() { return cancelledCreationWallet.createdAccounts == 1; }), + "cancelled creation should reach the wallet")) + return 1; + WalletAccount creationDiscoveredAccount; + creationDiscoveredAccount.address = QString(64, QLatin1Char('8')); + creationDiscoveredAccount.isPublic = true; + cancelledCreationRuntime.setWalletAccounts({ creationDiscoveredAccount }); + QVariantMap blockedCreationRetry; + cancelledCreationRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + blockedCreationRetry = std::move(result); + }); + if (!expect(cancelledCreationResult.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable") + && blockedCreationRetry.value(QStringLiteral("code")).toString() + == QStringLiteral("submit_in_progress"), + "account refresh should wait for active creation to settle")) + return 1; + cancelledCreationWallet.finishAccountCreation(); + const int creationReadsBeforeRetry = refreshClient.normalizedAccountIds.count( + QString(64, QLatin1Char('a'))); + QVariantMap creationRetryResult; + cancelledCreationRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + creationRetryResult = std::move(result); + }); + if (!expect(waitForCondition([&]() { + return !creationRetryResult.isEmpty(); + }), + "creation retry should complete")) + return 1; + if (!expect(creationRetryResult.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && cancelledCreationWallet.createdAccounts == 1 + && cancelledCreationClient.freshLpAccountIds.size() == 1 + && refreshClient.normalizedAccountIds.count( + QString(64, QLatin1Char('a'))) + > creationReadsBeforeRetry, + "late creation should be reserved for the retry")) + return 1; + + FakeWallet failedReadWallet; + failedReadWallet.failCreatedPublicRead = true; + FakeAmmClient failedReadClient; + NewPositionRuntime failedReadRuntime( + &failedReadWallet, &failedReadClient, &sequencer); + const int failedReadNormalizations = refreshClient.normalizedAccountIds.count( + QString(64, QLatin1Char('a'))); + QVariantMap failedReadResult; + failedReadRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + failedReadResult = std::move(result); + }); + if (!expect(waitForCondition([&]() { return !failedReadResult.isEmpty(); }), + "sequencer fallback should complete the failed wallet read")) + return 1; + if (!expect(failedReadResult.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && failedReadWallet.createdAccounts == 1 + && failedReadWallet.submissions == 1 + && refreshClient.normalizedAccountIds.count( + QString(64, QLatin1Char('a'))) + > failedReadNormalizations, + "sequencer should validate a newly created LP account")) + return 1; + + FakeWallet cancelledSubmissionWallet; + cancelledSubmissionWallet.deferSubmission = true; + cancelledSubmissionWallet.submissionFailuresRemaining = 1; + FakeAmmClient cancelledSubmissionClient; + NewPositionRuntime cancelledSubmissionRuntime( + &cancelledSubmissionWallet, &cancelledSubmissionClient, &sequencer); + QVariantMap cancelledSubmissionResult; + cancelledSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + cancelledSubmissionResult = std::move(result); + }); + if (!expect(waitForCondition( + [&]() { return cancelledSubmissionWallet.submissions == 1; }), + "cancelled submission should reach the wallet")) + return 1; + WalletAccount pendingFreshLp; + pendingFreshLp.address = QString(64, QLatin1Char('a')); + pendingFreshLp.isPublic = true; + WalletAccount discoveredAccount; + discoveredAccount.address = QString(64, QLatin1Char('9')); + discoveredAccount.isPublic = true; + cancelledSubmissionRuntime.setWalletAccounts( + { pendingFreshLp, discoveredAccount }); + QVariantMap blockedSubmissionRetry; + cancelledSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + blockedSubmissionRetry = std::move(result); + }); + if (!expect(cancelledSubmissionResult.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable") + && blockedSubmissionRetry.value(QStringLiteral("code")).toString() + == QStringLiteral("submit_in_progress"), + "retry should wait for cancelled wallet submission to settle")) + return 1; + cancelledSubmissionWallet.finishSubmission(); + cancelledSubmissionWallet.deferSubmission = false; + QVariantMap settledSubmissionRetry; + cancelledSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + settledSubmissionRetry = std::move(result); + }); + if (!expect(waitForCondition([&]() { + return !settledSubmissionRetry.isEmpty(); + }), + "settled submission retry should complete")) + return 1; + if (!expect(settledSubmissionRetry.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && cancelledSubmissionWallet.createdAccounts == 1 + && cancelledSubmissionWallet.submissions == 2 + && cancelledSubmissionClient.freshLpAccountIds.size() == 2 + && cancelledSubmissionClient.freshLpAccountIds.constFirst() + == cancelledSubmissionClient.freshLpAccountIds.constLast(), + "rejected late submission should restore the LP reservation")) + return 1; + + FakeWallet acceptedLateWallet; + acceptedLateWallet.deferSubmission = true; + FakeAmmClient acceptedLateClient; + NewPositionRuntime acceptedLateRuntime( + &acceptedLateWallet, &acceptedLateClient, &sequencer); + QVariantMap acceptedLateResult; + acceptedLateRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + acceptedLateResult = std::move(result); + }); + if (!expect(waitForCondition( + [&]() { return acceptedLateWallet.submissions == 1; }), + "late accepted submission should reach the wallet")) + return 1; + acceptedLateRuntime.cancelSubmit(); + QVariantMap acceptedLateBlockedRetry; + acceptedLateRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + acceptedLateBlockedRetry = std::move(result); + }); + if (!expect(acceptedLateResult.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable") + && acceptedLateBlockedRetry.value(QStringLiteral("code")).toString() + == QStringLiteral("submit_in_progress"), + "accepted late submission should block retry until settled")) + return 1; + acceptedLateWallet.finishSubmission(); + acceptedLateWallet.deferSubmission = false; + QVariantMap acceptedLateRetry; + acceptedLateRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + acceptedLateRetry = std::move(result); + }); + if (!expect(waitForCondition([&]() { return !acceptedLateRetry.isEmpty(); }), + "accepted late retry should complete")) + return 1; + if (!expect(acceptedLateRetry.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && acceptedLateWallet.createdAccounts == 2 + && acceptedLateWallet.submissions == 2 + && acceptedLateClient.freshLpAccountIds.size() == 2 + && acceptedLateClient.freshLpAccountIds.constFirst() + != acceptedLateClient.freshLpAccountIds.constLast(), + "accepted late submission should consume its LP reservation")) + return 1; + + FakeWallet changedDuringSubmissionWallet; + changedDuringSubmissionWallet.deferSubmission = true; + FakeAmmClient changedDuringSubmissionClient; + NewPositionRuntime changedDuringSubmissionRuntime( + &changedDuringSubmissionWallet, &changedDuringSubmissionClient, &sequencer); + QVariantMap changedDuringSubmissionResult; + changedDuringSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + changedDuringSubmissionResult = std::move(result); + }); + if (!expect(waitForCondition([&]() { + return changedDuringSubmissionWallet.submissions == 1; + }), + "wallet-change submission should reach the wallet")) + return 1; + changedDuringSubmissionRuntime.clearWalletAccounts(); + if (!expect(changedDuringSubmissionResult.value( + QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable"), + "wallet change should cancel deferred submission")) + return 1; + changedDuringSubmissionWallet.finishSubmission(); + changedDuringSubmissionWallet.deferSubmission = false; + QVariantMap changedWalletRetry; + changedDuringSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + changedWalletRetry = std::move(result); + }); + if (!expect(waitForCondition([&]() { return !changedWalletRetry.isEmpty(); }), + "new wallet submission should complete")) + return 1; + if (!expect(changedWalletRetry.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && changedDuringSubmissionWallet.createdAccounts == 2, + "old wallet callback should not mutate new reservation state")) + return 1; + FakeWallet cancelledMutationWallet; cancelledMutationWallet.deferAccountCreation = true; FakeAmmClient cancelledMutationClient;