fix(amm): submit wallet mutations asynchronously

This commit is contained in:
Ricardo Guilherme Schmidt 2026-07-17 20:29:19 -03:00
parent 02a65aaf09
commit cfaaba3bce
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
10 changed files with 807 additions and 132 deletions

View File

@ -191,6 +191,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();
@ -212,6 +213,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();

View File

@ -8,6 +8,7 @@
#include <QByteArray>
#include <QDateTime>
#include <QJsonObject>
#include <QPointer>
#include <QScopedValueRollback>
#include <libbase58.h>
@ -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<WalletAccount>& 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<NewPositionRuntime> guard(this);
m_sequencer->readAccounts({ configId }, refreshPublicData,
[this, request, network, walletOpen, refreshPublicData,
[guard, request, network, walletOpen, refreshPublicData,
callback = std::move(callback)](QVector<WalletAccountRead> 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<WalletAccountRead> 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<WalletAccountRead> 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<void(QJsonObject, QJsonObject)> callback) const
std::function<void(QJsonObject, QJsonObject)> 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<NewPositionRuntime> guard(this);
m_sequencer->readAccounts({ configId }, forceRefresh,
[this, request, network, walletOpen, forceRefresh,
[guard, request, network, walletOpen, forceRefresh,
callback = std::move(callback)](QVector<WalletAccountRead> 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<WalletAccountRead> 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<WalletAccountRead> 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<NewPositionRuntime> 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<NewPositionRuntime> 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<bool> signingRequirements = jsonBoolList(
plan.value(QStringLiteral("signingRequirements")).toArray());
const QVector<quint32> instruction = jsonUIntList(
plan.value(QStringLiteral("instruction")).toArray());
bool deadlineValid = false;
const qulonglong deadline = plan.value(QStringLiteral("deadlineMs"))
.toString().toULongLong(&deadlineValid);
if (!deadlineValid
|| static_cast<qulonglong>(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<qulonglong>(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<NewPositionRuntime> 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());
});
}

View File

@ -2,6 +2,7 @@
#include <QJsonArray>
#include <QJsonObject>
#include <QObject>
#include <QString>
#include <QVariantMap>
#include <QVector>
@ -15,7 +16,7 @@ class WalletProvider;
class SequencerClient;
struct WalletAccount;
class NewPositionRuntime {
class NewPositionRuntime : public QObject {
public:
using ResultCallback = std::function<void(QVariantMap)>;
@ -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<void(QJsonObject, QJsonObject)> callback) const;
std::function<void(QJsonObject, QJsonObject)> 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;
};

View File

@ -15,6 +15,8 @@
#include <QTemporaryFile>
#include <QTimer>
#include <functional>
#include <memory>
#include <utility>
namespace {
@ -143,6 +145,15 @@ namespace {
return first && second;
}
bool waitForCondition(const std::function<bool()>& 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<NewPositionRuntime>(
&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;
}

View File

@ -7,6 +7,7 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QPointer>
#include <QTimer>
#include <QVariantList>
#include <QVariantMap>
@ -109,6 +110,58 @@ WalletAccountRead parsePublicAccount(const QString& accountId, const QString& pa
read.dataHex = data;
return read;
}
bool encodeTransaction(const WalletTransaction& transaction,
QVariantList* signingRequirements,
QVariantList* instruction)
{
if (!isHex(transaction.programId, 64)
|| transaction.accountIds.size() != transaction.signingRequirements.size()) {
return false;
}
for (const QString& accountId : transaction.accountIds) {
if (!isHex(accountId, 64))
return false;
}
signingRequirements->reserve(transaction.signingRequirements.size());
for (bool required : transaction.signingRequirements)
signingRequirements->append(required);
instruction->reserve(transaction.instruction.size());
for (quint32 word : transaction.instruction)
instruction->append(word);
return true;
}
WalletSubmission parseSubmission(const QString& response)
{
WalletSubmission submission;
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
}
const QJsonObject result = document.object();
const QJsonValue success = result.value(QStringLiteral("success"));
const QJsonValue error = result.value(QStringLiteral("error"));
const QString hash = result.value(QStringLiteral("tx_hash")).toString();
const bool emptyError = error.isUndefined()
|| error.isNull()
|| (error.isString() && error.toString().isEmpty());
if (!success.isBool()
|| !success.toBool()
|| !emptyError
|| !isHex(hash, 64, false)) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
}
submission.nativeHash = hash.toLower();
return submission;
}
}
struct LogosWalletProvider::Impl {
@ -138,12 +191,16 @@ LogosWalletProvider::LogosWalletProvider(LogosModules* logos)
LogosWalletProvider::~LogosWalletProvider()
{
++m_generation;
++m_sessionGeneration;
if (m_connected)
save();
}
WalletSession LogosWalletProvider::connect(const WalletPaths& paths)
{
++m_generation;
++m_sessionGeneration;
clearSnapshot();
if (!m_impl->logos)
return failedSession(WalletFailure::WalletUnavailable);
@ -168,6 +225,7 @@ WalletSession LogosWalletProvider::connect(const WalletPaths& paths)
void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback callback)
{
++m_sessionGeneration;
clearSnapshot();
const quint64 generation = ++m_generation;
if (!m_impl->logos) {
@ -239,6 +297,8 @@ void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback
WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths,
const QString& password)
{
++m_generation;
++m_sessionGeneration;
clearSnapshot();
if (!m_impl->logos)
return failedCreation(WalletFailure::WalletUnavailable);
@ -368,6 +428,167 @@ WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic)
return creation;
}
void LogosWalletProvider::createAccountAsync(bool isPublic,
AccountCreationCallback callback)
{
QPointer<LogosWalletProvider> guard(this);
if (!m_connected || !m_impl->logos) {
QTimer::singleShot(0, [guard, callback = std::move(callback)]() mutable {
if (!guard)
return;
WalletAccountCreation creation;
creation.failure = WalletFailure::WalletUnavailable;
callback(std::move(creation));
});
return;
}
const quint64 sessionGeneration = m_sessionGeneration;
auto finish = [guard, sessionGeneration, isPublic,
callback = std::move(callback)](
WalletAccountCreation creation, QString fallbackBalance) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
callback(std::move(failed));
return;
}
if (guard->m_snapshotReady) {
WalletAccount account;
account.address = creation.accountId;
account.isPublic = isPublic;
if (isPublic && creation.publicAccount.ok()) {
account.balance = littleEndianU128ToDecimal(
creation.publicAccount.balanceHex);
auto read = std::find_if(
guard->m_snapshot.publicAccountReads.begin(),
guard->m_snapshot.publicAccountReads.end(),
[&creation](const WalletAccountRead& existing) {
return existing.accountId == creation.accountId;
});
if (read == guard->m_snapshot.publicAccountReads.end())
guard->m_snapshot.publicAccountReads.append(creation.publicAccount);
else
*read = creation.publicAccount;
} else {
account.balance = std::move(fallbackBalance);
}
auto existing = std::find_if(
guard->m_snapshot.accounts.begin(),
guard->m_snapshot.accounts.end(),
[&creation](const WalletAccount& candidate) {
return candidate.address == creation.accountId;
});
if (existing == guard->m_snapshot.accounts.end())
guard->m_snapshot.accounts.append(account);
else
*existing = account;
creation.snapshot = guard->m_snapshot;
}
callback(std::move(creation));
};
auto created = [guard, sessionGeneration, isPublic,
finish = std::move(finish)](QString accountId) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
WalletAccountCreation creation;
creation.accountId = std::move(accountId);
if (!isHex(creation.accountId, 64)) {
creation.failure = WalletFailure::CreateFailed;
finish(std::move(creation), {});
return;
}
guard->m_impl->logos->logos_execution_zone.saveAsync(
[guard, sessionGeneration, isPublic, creation = std::move(creation),
finish = std::move(finish)](int result) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
if (result != WALLET_FFI_SUCCESS) {
creation.failure = WalletFailure::SaveFailed;
finish(std::move(creation), {});
return;
}
if (!isPublic) {
guard->m_impl->logos->logos_execution_zone.get_balanceAsync(
creation.accountId, false,
[guard, sessionGeneration, creation = std::move(creation),
finish = std::move(finish)](QString balance) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
finish(std::move(creation), std::move(balance));
});
return;
}
const QString accountId = creation.accountId;
guard->m_impl->logos->logos_execution_zone.get_account_publicAsync(
accountId,
[guard, sessionGeneration, creation = std::move(creation),
finish = std::move(finish)](QString payload) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
creation.publicAccount = parsePublicAccount(
creation.accountId, payload);
if (creation.publicAccount.ok()) {
finish(std::move(creation), {});
return;
}
guard->m_impl->logos->logos_execution_zone.get_balanceAsync(
creation.accountId, true,
[guard, sessionGeneration,
creation = std::move(creation),
finish = std::move(finish)](QString balance) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
finish(std::move(creation), std::move(balance));
});
});
});
};
if (isPublic)
m_impl->logos->logos_execution_zone.create_account_publicAsync(std::move(created));
else
m_impl->logos->logos_execution_zone.create_account_privateAsync(std::move(created));
}
WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const
{
if (!m_impl->logos || !isHex(accountId, 64))
@ -385,27 +606,12 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction(
submission.failure = WalletFailure::WalletUnavailable;
return submission;
}
if (!isHex(transaction.programId, 64)
|| transaction.accountIds.size() != transaction.signingRequirements.size()) {
QVariantList signingRequirements;
QVariantList instruction;
if (!encodeTransaction(transaction, &signingRequirements, &instruction)) {
submission.failure = WalletFailure::InvalidRequest;
return submission;
}
for (const QString& accountId : transaction.accountIds) {
if (!isHex(accountId, 64)) {
submission.failure = WalletFailure::InvalidRequest;
return submission;
}
}
QVariantList signingRequirements;
signingRequirements.reserve(transaction.signingRequirements.size());
for (bool required : transaction.signingRequirements)
signingRequirements.append(required);
QVariantList instruction;
instruction.reserve(transaction.instruction.size());
for (quint32 word : transaction.instruction)
instruction.append(word);
const QString response =
m_impl->logos->logos_execution_zone.send_generic_public_transaction(
@ -413,36 +619,60 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction(
signingRequirements,
QVariant::fromValue(instruction),
transaction.programId);
return parseSubmission(response);
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
void LogosWalletProvider::submitPublicTransactionAsync(
const WalletTransaction& transaction, SubmissionCallback callback)
{
QPointer<LogosWalletProvider> guard(this);
WalletSubmission submission;
if (!m_connected || !m_impl->logos) {
submission.failure = WalletFailure::WalletUnavailable;
QTimer::singleShot(0, [guard, callback = std::move(callback),
submission = std::move(submission)]() mutable {
if (guard)
callback(std::move(submission));
});
return;
}
const QJsonObject result = document.object();
const QJsonValue success = result.value(QStringLiteral("success"));
const QJsonValue error = result.value(QStringLiteral("error"));
const QString hash = result.value(QStringLiteral("tx_hash")).toString();
const bool emptyError = error.isUndefined()
|| error.isNull()
|| (error.isString() && error.toString().isEmpty());
if (!success.isBool()
|| !success.toBool()
|| !emptyError
|| !isHex(hash, 64, false)) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
QVariantList signingRequirements;
QVariantList instruction;
if (!encodeTransaction(transaction, &signingRequirements, &instruction)) {
submission.failure = WalletFailure::InvalidRequest;
QTimer::singleShot(0, [guard, callback = std::move(callback),
submission = std::move(submission)]() mutable {
if (guard)
callback(std::move(submission));
});
return;
}
submission.nativeHash = hash.toLower();
return submission;
const quint64 sessionGeneration = m_sessionGeneration;
m_impl->logos->logos_execution_zone.send_generic_public_transactionAsync(
transaction.accountIds,
signingRequirements,
QVariant::fromValue(instruction),
transaction.programId,
[guard, sessionGeneration, callback = std::move(callback)](
QString response) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletSubmission failed;
failed.failure = WalletFailure::WalletUnavailable;
callback(std::move(failed));
return;
}
callback(parseSubmission(response));
});
}
void LogosWalletProvider::disconnect()
{
++m_generation;
++m_sessionGeneration;
if (m_connected)
save();
clearSnapshot();

View File

@ -2,12 +2,14 @@
#include <memory>
#include <QObject>
#include "WalletProvider.h"
class LogosAPI;
struct LogosModules;
class LogosWalletProvider final : public WalletProvider {
class LogosWalletProvider final : public QObject, public WalletProvider {
public:
explicit LogosWalletProvider(LogosAPI* api);
explicit LogosWalletProvider(LogosModules* logos);
@ -21,9 +23,12 @@ public:
void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override;
void clearSnapshot() override;
WalletAccountCreation createAccount(bool isPublic) override;
void createAccountAsync(bool isPublic, AccountCreationCallback callback) override;
WalletAccountRead readPublicAccount(const QString& accountId) const override;
WalletSubmission submitPublicTransaction(
const WalletTransaction& transaction) override;
void submitPublicTransactionAsync(
const WalletTransaction& transaction, SubmissionCallback callback) override;
void disconnect() override;
private:
@ -38,4 +43,5 @@ private:
bool m_snapshotReady = false;
bool m_connected = false;
quint64 m_generation = 0;
quint64 m_sessionGeneration = 0;
};

View File

@ -95,6 +95,8 @@ class WalletProvider {
public:
using SessionCallback = std::function<void(WalletSession)>;
using SnapshotCallback = std::function<void(WalletSnapshot)>;
using AccountCreationCallback = std::function<void(WalletAccountCreation)>;
using SubmissionCallback = std::function<void(WalletSubmission)>;
virtual ~WalletProvider() = default;
@ -106,8 +108,11 @@ public:
virtual void snapshotAsync(bool forceRefresh, SnapshotCallback callback) = 0;
virtual void clearSnapshot() = 0;
virtual WalletAccountCreation createAccount(bool isPublic) = 0;
virtual void createAccountAsync(bool isPublic, AccountCreationCallback callback) = 0;
virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0;
virtual WalletSubmission submitPublicTransaction(
const WalletTransaction& transaction) = 0;
virtual void submitPublicTransactionAsync(
const WalletTransaction& transaction, SubmissionCallback callback) = 0;
virtual void disconnect() = 0;
};

View File

@ -61,6 +61,9 @@ private slots:
void createdAccountDoesNotRescanWallet();
void dispatchesExactGenericTransaction();
void rejectsInvalidSubmissionResponses();
void walletMutationsUseAsyncSdk();
void staleAsyncMutationCannotCrossSession();
void destroyedProviderIgnoresLateMutation();
void exposesStableAccountModelRoles();
void fakeProviderImplementsConsumerContract();
void controllerOwnsUiWalletFlow();
@ -349,6 +352,108 @@ void LogosWalletProviderTest::rejectsInvalidSubmissionResponses()
WalletFailure::InvalidRequest);
}
void LogosWalletProviderTest::walletMutationsUseAsyncSdk()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.publicAccountId = ACCOUNT_A;
modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson());
modules.logos_execution_zone.transactionResponse = QStringLiteral(
R"({"success":true,"tx_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"})");
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
modules.logos_execution_zone.deferPublicAccountCreation = true;
bool creationFinished = false;
WalletAccountCreation creation;
const int listCalls = modules.logos_execution_zone.listCalls;
const int syncCalls = modules.logos_execution_zone.syncCalls;
provider.createAccountAsync(true, [&](WalletAccountCreation result) {
creation = std::move(result);
creationFinished = true;
});
QVERIFY(!creationFinished);
QVERIFY(modules.logos_execution_zone.pendingPublicAccountCreation);
modules.logos_execution_zone.finishPublicAccountCreation();
QVERIFY(creationFinished);
QVERIFY(creation.ok());
QVERIFY(creation.publicAccount.ok());
QCOMPARE(creation.accountId, ACCOUNT_A);
QCOMPARE(creation.snapshot.accounts.size(), 1);
QCOMPARE(modules.logos_execution_zone.listCalls, listCalls);
QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls);
WalletTransaction transaction {
PROGRAM_ID,
{ ACCOUNT_A, ACCOUNT_B },
{ true, false },
{ 7, 0, 4294967295U },
};
modules.logos_execution_zone.deferSubmission = true;
bool submissionFinished = false;
WalletSubmission submission;
provider.submitPublicTransactionAsync(
transaction, [&](WalletSubmission result) {
submission = std::move(result);
submissionFinished = true;
});
QVERIFY(!submissionFinished);
QCOMPARE(modules.logos_execution_zone.submittedProgramId, PROGRAM_ID);
QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds);
QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements,
QVariantList({ true, false }));
QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(),
QVariantList({ 7U, 0U, 4294967295U }));
modules.logos_execution_zone.finishSubmission();
QVERIFY(submissionFinished);
QVERIFY(submission.accepted());
QCOMPARE(submission.nativeHash, QString(64, QLatin1Char('a')));
}
void LogosWalletProviderTest::staleAsyncMutationCannotCrossSession()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.publicAccountId = ACCOUNT_A;
modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson());
modules.logos_execution_zone.deferPublicAccountCreation = true;
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
int callbackCount = 0;
WalletAccountCreation creation;
provider.createAccountAsync(true, [&](WalletAccountCreation result) {
++callbackCount;
creation = std::move(result);
});
provider.disconnect();
modules.logos_execution_zone.finishPublicAccountCreation();
QCOMPARE(callbackCount, 1);
QCOMPARE(creation.failure, WalletFailure::WalletUnavailable);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0);
}
void LogosWalletProviderTest::destroyedProviderIgnoresLateMutation()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.transactionResponse = QStringLiteral(
R"({"success":true,"tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})");
modules.logos_execution_zone.deferSubmission = true;
int callbackCount = 0;
{
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
provider.submitPublicTransactionAsync(
{ PROGRAM_ID, { ACCOUNT_A }, { true }, { 1 } },
[&](WalletSubmission) { ++callbackCount; });
}
modules.logos_execution_zone.finishSubmission();
QCOMPARE(callbackCount, 0);
}
void LogosWalletProviderTest::exposesStableAccountModelRoles()
{
WalletAccountModel model;

View File

@ -7,6 +7,7 @@
#include <QVariantList>
#include <functional>
#include <utility>
class LogosAPI;
@ -32,6 +33,10 @@ public:
int listCalls = 0;
int publicReadCalls = 0;
int submitCalls = 0;
bool deferPublicAccountCreation = false;
bool deferSubmission = false;
std::function<void(QString)> pendingPublicAccountCreation;
std::function<void(QString)> pendingSubmission;
QString openedConfig;
QString openedStorage;
QString createdConfig;
@ -77,6 +82,17 @@ public:
QString create_account_public() { return publicAccountId; }
QString create_account_private() { return privateAccountId; }
void create_account_publicAsync(std::function<void(QString)> callback)
{
if (deferPublicAccountCreation)
pendingPublicAccountCreation = std::move(callback);
else
callback(create_account_public());
}
void create_account_privateAsync(std::function<void(QString)> callback)
{
callback(create_account_private());
}
int get_last_synced_block() const { return lastSyncedBlock; }
int get_current_block_height() const { return currentBlockHeight; }
@ -150,6 +166,38 @@ public:
submittedProgramId = programId;
return transactionResponse;
}
void send_generic_public_transactionAsync(
const QStringList& accountIds,
const QVariantList& signingRequirements,
const QVariant& instruction,
const QString& programId,
std::function<void(QString)> callback)
{
++submitCalls;
submittedAccountIds = accountIds;
submittedSigningRequirements = signingRequirements;
submittedInstruction = instruction;
submittedProgramId = programId;
if (deferSubmission)
pendingSubmission = std::move(callback);
else
callback(transactionResponse);
}
void finishPublicAccountCreation()
{
auto callback = std::move(pendingPublicAccountCreation);
if (callback)
callback(publicAccountId);
}
void finishSubmission()
{
auto callback = std::move(pendingSubmission);
if (callback)
callback(transactionResponse);
}
};
struct LogosModules {

View File

@ -80,6 +80,11 @@ public:
return createAccountResult;
}
void createAccountAsync(bool isPublic, AccountCreationCallback callback) override
{
callback(createAccount(isPublic));
}
WalletAccountRead readPublicAccount(const QString& accountId) const override
{
++readCalls;
@ -96,6 +101,12 @@ public:
return submissionResult;
}
void submitPublicTransactionAsync(
const WalletTransaction& transaction, SubmissionCallback callback) override
{
callback(submitPublicTransaction(transaction));
}
void disconnect() override { ++disconnectCalls; }
void finishConnect()