feat(wallet): humanize shared wallet experience

Consolidate wallet account decoding and portfolio handling around the shared Rust IDL decoder. Simplify AMM wallet integration, remove obsolete caches and network plumbing, and cover account selection and live flows.
This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-21 17:38:45 -03:00
parent 62d133e909
commit 8c3e6fccfe
64 changed files with 6268 additions and 27501 deletions
@@ -1,13 +1,20 @@
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QHostAddress>
#include <QNetworkAccessManager>
#include <QSettings>
#include <QSignalSpy>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTemporaryDir>
#include <QTimer>
#include <QtTest>
#include <memory>
#include <utility>
#include "FakeWalletProvider.h"
#include "LogosWalletProvider.h"
#include "WalletAccountModel.h"
@@ -17,7 +24,33 @@
namespace {
const QString ACCOUNT_A(64, QLatin1Char('a'));
const QString ACCOUNT_B(64, QLatin1Char('b'));
const QString ACCOUNT_C(64, QLatin1Char('d'));
const QString PROGRAM_ID(64, QLatin1Char('c'));
const QString EOA_OWNER(64, QLatin1Char('0'));
class ScopedEnvironment final {
public:
ScopedEnvironment(QByteArray name, QByteArray value)
: m_name(std::move(name)),
m_hadValue(qEnvironmentVariableIsSet(m_name.constData())),
m_previous(qgetenv(m_name.constData()))
{
qputenv(m_name.constData(), value);
}
~ScopedEnvironment()
{
if (m_hadValue)
qputenv(m_name.constData(), m_previous);
else
qunsetenv(m_name.constData());
}
private:
QByteArray m_name;
bool m_hadValue;
QByteArray m_previous;
};
QString publicAccountJson(const QString& owner = PROGRAM_ID,
const QString& balance = QStringLiteral("01000000000000000000000000000000"),
@@ -39,6 +72,7 @@ QVariantMap accountEntry(const QString& id, bool isPublic)
{ QStringLiteral("is_public"), isPublic },
};
}
}
class LogosWalletProviderTest : public QObject {
@@ -47,18 +81,33 @@ class LogosWalletProviderTest : public QObject {
private slots:
void adoptsOpenWalletAndCachesSnapshots();
void opensConfiguredWalletWhenNoSharedSessionExists();
void opensStoredWalletAsynchronouslyWithoutAccountProbe();
void opensAndReadsAsynchronously();
void avoidsSavingAfterUnchangedAsynchronousSnapshots();
void createsAndPersistsWallet();
void validatesCompletePublicAccountPayloads();
void fallsBackToBalanceWhenPublicReadFails();
void createsAndPersistsAccounts();
void preservesCreatedAccountWhenPublicReadFails();
void preservesCreatedAccountWhenSnapshotRefreshFails();
void createdAccountDoesNotRescanWallet();
void dispatchesExactGenericTransaction();
void rejectsInvalidSubmissionResponses();
void exposesStableAccountModelRoles();
void clearsStaleAccountPresentationsWithoutInvalidatingPrimary();
void persistsHumanizedWalletPreferences();
void fakeProviderImplementsConsumerContract();
void controllerOwnsUiWalletFlow();
void controllerSeparatesSnapshotsFromCosmeticState();
void controllerOpenDoesNotWaitForWalletSync();
void controllerCreationDoesNotWaitForWalletSync();
void controllerSeedsDefaultWalletConfigWithConfiguredEndpoint();
void controllerPreservesExistingDefaultWalletConfig();
void controllerStopsReachabilityChecksAfterDisconnect();
void completedAsyncSnapshotReleasesCallback();
void deferredCallbacksIgnoreDestroyedController();
void newerReachabilityResultWins();
void coalescesReachabilityChecksForSameEndpoint();
void controllerReportsPartialWalletCreation();
};
void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
@@ -84,11 +133,18 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1"));
QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42"));
QCOMPARE(session.snapshot.publicAccountReads.size(), 1);
QCOMPARE(session.snapshot.accounts.at(0).readStatus, QStringLiteral("ok"));
QCOMPARE(session.snapshot.accounts.at(0).programOwner, PROGRAM_ID);
QCOMPARE(session.snapshot.accounts.at(0).dataHex, QStringLiteral("00ff"));
QCOMPARE(session.snapshot.accounts.at(0).displayAddress,
QStringLiteral("base58-") + ACCOUNT_A);
QCOMPARE(session.snapshot.accounts.at(1).readStatus, QStringLiteral("private"));
QCOMPARE(session.snapshot.currentBlockHeight, quint64(12));
QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11));
const int listCalls = modules.logos_execution_zone.listCalls;
const int readCalls = modules.logos_execution_zone.publicReadCalls;
const int saveCalls = modules.logos_execution_zone.saveCalls;
QVERIFY(provider.snapshot().ok());
QCOMPARE(modules.logos_execution_zone.listCalls, listCalls);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls);
@@ -96,6 +152,7 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
QVERIFY(provider.snapshot(true).ok());
QVERIFY(modules.logos_execution_zone.listCalls > listCalls);
QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls);
QCOMPARE(modules.logos_execution_zone.saveCalls, saveCalls);
modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(
PROGRAM_ID, QString(32, QLatin1Char('f')));
@@ -131,6 +188,7 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists()
QVERIFY(!session.adopted);
QCOMPARE(modules.logos_execution_zone.openCalls, 1);
QCOMPARE(modules.logos_execution_zone.openedStorage, storage);
QCOMPARE(modules.logos_execution_zone.listCalls, 1);
LogosModules missingModules;
LogosWalletProvider missingProvider(&missingModules);
@@ -138,12 +196,83 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists()
WalletFailure::WalletMissing);
}
void LogosWalletProviderTest::opensStoredWalletAsynchronouslyWithoutAccountProbe()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString storage = directory.filePath(QStringLiteral("storage.json"));
QFile file(storage);
QVERIFY(file.open(QIODevice::WriteOnly));
file.close();
LogosModules modules;
LogosWalletProvider provider(&modules);
bool connected = false;
provider.connectAsync({ directory.filePath(QStringLiteral("wallet.json")), storage },
[&connected](WalletSession session) {
connected = session.ok() && !session.adopted;
});
QVERIFY(connected);
QCOMPARE(modules.logos_execution_zone.openCalls, 1);
QCOMPARE(modules.logos_execution_zone.listCalls, 1);
}
void LogosWalletProviderTest::opensAndReadsAsynchronously()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
modules.logos_execution_zone.publicAccounts.insert(
ACCOUNT_A, publicAccountJson(EOA_OWNER));
LogosWalletProvider provider(&modules);
bool connected = false;
provider.connectAsync({}, [&connected](WalletSession session) {
connected = session.ok() && session.snapshot.accounts.size() == 1;
});
QVERIFY(connected);
bool refreshed = false;
provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) {
refreshed = snapshot.ok() && snapshot.accounts.at(0).programOwner == EOA_OWNER;
});
QVERIFY(refreshed);
}
void LogosWalletProviderTest::avoidsSavingAfterUnchangedAsynchronousSnapshots()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.currentBlockHeight = 12;
modules.logos_execution_zone.lastSyncedBlock = 12;
LogosWalletProvider provider(&modules);
bool connected = false;
provider.connectAsync({}, [&connected](WalletSession session) {
connected = session.ok();
});
QVERIFY(connected);
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
bool refreshed = false;
provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) {
refreshed = snapshot.ok();
});
QVERIFY(refreshed);
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
}
void LogosWalletProviderTest::createsAndPersistsWallet()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
LogosModules modules;
modules.logos_execution_zone.currentBlockHeight = 12;
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson());
LogosWalletProvider provider(&modules);
const WalletPaths paths {
directory.filePath(QStringLiteral("config/wallet.json")),
@@ -157,6 +286,9 @@ void LogosWalletProviderTest::createsAndPersistsWallet()
QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage);
QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret"));
QVERIFY(modules.logos_execution_zone.saveCalls >= 1);
QCOMPARE(modules.logos_execution_zone.syncCalls, 0);
QCOMPARE(modules.logos_execution_zone.listCalls, 0);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0);
LogosModules rejectedModules;
rejectedModules.logos_execution_zone.mnemonic.clear();
@@ -227,12 +359,14 @@ void LogosWalletProviderTest::createsAndPersistsAccounts()
QVERIFY(provider.connect({}).ok());
const int savesBeforeCreate = modules.logos_execution_zone.saveCalls;
const int publicReadsBeforeCreate = modules.logos_execution_zone.publicReadCalls;
const WalletAccountCreation creation = provider.createAccount(true);
QVERIFY(creation.ok());
QCOMPARE(creation.accountId, ACCOUNT_A);
QVERIFY(creation.publicAccount.ok());
QCOMPARE(creation.snapshot.accounts.size(), 1);
QVERIFY(modules.logos_execution_zone.saveCalls > savesBeforeCreate);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, publicReadsBeforeCreate + 1);
modules.logos_execution_zone.saveResult = 1;
QCOMPARE(provider.createAccount(true).failure, WalletFailure::SaveFailed);
@@ -257,7 +391,7 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenPublicReadFails()
QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7"));
}
void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails()
void LogosWalletProviderTest::createdAccountDoesNotRescanWallet()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
@@ -268,11 +402,13 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails()
modules.logos_execution_zone.currentBlockHeight = 1;
modules.logos_execution_zone.syncResult = 1;
const int syncCalls = modules.logos_execution_zone.syncCalls;
const WalletAccountCreation creation = provider.createAccount(true);
QVERIFY(creation.ok());
QCOMPARE(creation.accountId, ACCOUNT_A);
QCOMPARE(creation.snapshot.failure, WalletFailure::ReadFailed);
QVERIFY(creation.snapshot.ok());
QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls);
}
void LogosWalletProviderTest::dispatchesExactGenericTransaction()
@@ -298,8 +434,8 @@ void LogosWalletProviderTest::dispatchesExactGenericTransaction()
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 }));
QCOMPARE(modules.logos_execution_zone.submittedInstruction.toByteArray(),
QByteArray::fromHex("0700000000000000ffffffff"));
}
void LogosWalletProviderTest::rejectsInvalidSubmissionResponses()
@@ -338,19 +474,171 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles()
WalletAccountModel model;
QSignalSpy countChanged(&model, &WalletAccountModel::countChanged);
model.replaceAccounts({
{ ACCOUNT_A, QStringLiteral("10"), true },
{ ACCOUNT_B, QStringLiteral("20"), false },
});
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
{ ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} },
{ ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, QStringLiteral("00") },
}, { { ACCOUNT_A, QStringLiteral("Trading") } }, ACCOUNT_A);
QCOMPARE(model.count(), 2);
QCOMPARE(model.count(), 3);
QCOMPARE(countChanged.count(), 1);
QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name"));
QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(),
QStringLiteral("Account 1"));
QStringLiteral("Trading"));
QCOMPARE(model.data(model.index(0), WalletAccountModel::KindRole).toString(),
QStringLiteral("user"));
QVERIFY(model.data(model.index(0), WalletAccountModel::CanBePrimaryRole).toBool());
QVERIFY(model.data(model.index(0), WalletAccountModel::IsPrimaryRole).toBool());
QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B);
QCOMPARE(model.roleNames().value(WalletAccountModel::DisplayAddressRole),
QByteArray("displayAddress"));
QCOMPARE(model.roleNames().value(WalletAccountModel::DecodedDataRole),
QByteArray("decodedData"));
QCOMPARE(model.data(model.index(1), WalletAccountModel::DisplayAddressRole).toString(),
ACCOUNT_B);
QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(),
QStringLiteral("20"));
QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool());
QCOMPARE(model.data(model.index(2), WalletAccountModel::KindRole).toString(),
QStringLiteral("program"));
QVERIFY(!model.data(model.index(2), WalletAccountModel::CanBePrimaryRole).toBool());
QSignalSpy presentationsChanged(&model, &QAbstractItemModel::dataChanged);
const QVector<WalletAccountPresentation> presentations {
{
ACCOUNT_A,
QStringLiteral("program"),
{},
QStringLiteral("System"),
QStringLiteral("UserAccount"),
{},
false,
QStringLiteral("{\"public_key\":\"Public/test\"}"),
},
{
ACCOUNT_C,
QStringLiteral("token_holding"),
QStringLiteral("TEST holding"),
QStringLiteral("Token"),
QStringLiteral("TokenHolding"),
ACCOUNT_A,
true,
},
};
model.applyPresentations(presentations);
QCOMPARE(presentationsChanged.count(), 1);
QCOMPARE(model.data(model.index(2), WalletAccountModel::SectionRole).toString(),
QStringLiteral("hidden"));
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
QStringLiteral("TEST holding"));
QCOMPARE(model.data(model.index(0), WalletAccountModel::DecodedDataRole).toString(),
QStringLiteral("{\"public_key\":\"Public/test\"}"));
model.setAlias(ACCOUNT_C, QStringLiteral("Reserve"));
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
QStringLiteral("Reserve"));
model.setAlias(ACCOUNT_C, {});
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
QStringLiteral("TEST holding"));
QSignalSpy redundantPresentation(&model, &QAbstractItemModel::dataChanged);
QVERIFY(!model.applyPresentations(presentations));
QCOMPARE(redundantPresentation.count(), 0);
}
void LogosWalletProviderTest::clearsStaleAccountPresentationsWithoutInvalidatingPrimary()
{
const QString settingsApplication = QStringLiteral("WalletPresentationClearingTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.connectResult.adopted = true;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
{ ACCOUNT_C, QStringLiteral("20"), true, QStringLiteral("ok"), PROGRAM_ID,
QStringLiteral("00ff") },
};
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("User account"));
controller.applyAccountPresentations({
{
ACCOUNT_C,
QStringLiteral("token_holding"),
QStringLiteral("TEST holding"),
QStringLiteral("Token"),
QStringLiteral("TokenHolding"),
ACCOUNT_A,
true,
QStringLiteral("{\"amount\":\"20\"}"),
},
});
WalletAccountModel* model = controller.accountModel();
const QModelIndex holding = model->index(model->indexOf(ACCOUNT_C));
QCOMPARE(model->data(holding, WalletAccountModel::KindRole).toString(),
QStringLiteral("token_holding"));
QCOMPARE(model->data(holding, WalletAccountModel::SectionRole).toString(),
QStringLiteral("hidden"));
QCOMPARE(model->data(holding, WalletAccountModel::NameRole).toString(),
QStringLiteral("TEST holding"));
QCOMPARE(model->data(holding, WalletAccountModel::DecodedDataRole).toString(),
QStringLiteral("{\"amount\":\"20\"}"));
controller.applyAccountPresentations({});
QCOMPARE(model->data(holding, WalletAccountModel::KindRole).toString(),
QStringLiteral("program"));
QCOMPARE(model->data(holding, WalletAccountModel::SectionRole).toString(),
QStringLiteral("advanced"));
QCOMPARE(model->data(holding, WalletAccountModel::NameRole).toString(),
QStringLiteral("Program account"));
QCOMPARE(model->data(holding, WalletAccountModel::ProgramNameRole).toString(), QString());
QCOMPARE(model->data(holding, WalletAccountModel::AccountTypeRole).toString(), QString());
QCOMPARE(model->data(holding, WalletAccountModel::DefinitionIdRole).toString(), QString());
QCOMPARE(model->data(holding, WalletAccountModel::DecodedDataRole).toString(), QString());
QVERIFY(!model->data(holding, WalletAccountModel::CanBePrimaryRole).toBool());
const QModelIndex primary = model->index(model->indexOf(ACCOUNT_A));
QVERIFY(model->data(primary, WalletAccountModel::CanBePrimaryRole).toBool());
QVERIFY(model->data(primary, WalletAccountModel::IsPrimaryRole).toBool());
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("User account"));
settings.clear();
}
void LogosWalletProviderTest::persistsHumanizedWalletPreferences()
{
const QString application = QStringLiteral("HumanizedWalletPreferencesTest");
QSettings settings(QStringLiteral("Logos"), application);
settings.clear();
FakeWalletProvider provider;
provider.connectResult.adopted = true;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
{ ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} },
{ ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, {} },
};
{
WalletController controller(provider, application);
QVERIFY(controller.open());
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
QVERIFY(!controller.setPrimaryAccount(ACCOUNT_C));
QVERIFY(controller.setAccountAlias(ACCOUNT_B, QStringLiteral(" Private savings ")));
QVERIFY(controller.setPrimaryAccount(ACCOUNT_B));
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("Private savings"));
QVERIFY(!controller.setAccountAlias(ACCOUNT_A, QString(41, QLatin1Char('x'))));
}
WalletController reopened(provider, application);
QVERIFY(reopened.open());
QCOMPARE(reopened.state().primaryAccountAddress, ACCOUNT_B);
QCOMPARE(reopened.state().primaryAccountName, QStringLiteral("Private savings"));
settings.clear();
}
void LogosWalletProviderTest::fakeProviderImplementsConsumerContract()
@@ -419,6 +707,164 @@ void LogosWalletProviderTest::controllerOwnsUiWalletFlow()
settings.clear();
}
void LogosWalletProviderTest::controllerSeparatesSnapshotsFromCosmeticState()
{
const QString settingsApplication = QStringLiteral("WalletSnapshotSignalTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("5"), true },
};
provider.snapshotResult = provider.connectResult.snapshot;
WalletController controller(provider, settingsApplication);
QSignalSpy stateChanged(&controller, &WalletController::stateChanged);
QSignalSpy snapshotChanged(&controller, &WalletController::snapshotChanged);
QVERIFY(controller.open());
stateChanged.clear();
snapshotChanged.clear();
QVERIFY(controller.setAccountAlias(ACCOUNT_A, QStringLiteral("Spending")));
QCOMPARE(stateChanged.count(), 1);
QCOMPARE(snapshotChanged.count(), 0);
controller.refresh();
QCOMPARE(stateChanged.count(), 3);
QCOMPARE(snapshotChanged.count(), 1);
settings.clear();
}
void LogosWalletProviderTest::controllerOpenDoesNotWaitForWalletSync()
{
const QString settingsApplication = QStringLiteral("WalletAsyncOpenTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("5"), true },
};
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QCOMPARE(provider.connectCalls, 1);
QVERIFY(!controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("opening"));
QCOMPARE(controller.accountModel()->count(), 0);
provider.finishConnect();
QVERIFY(controller.state().isWalletOpen);
QVERIFY(controller.state().canSubmit());
QCOMPARE(controller.state().syncStatus, QStringLiteral("ready"));
QCOMPARE(controller.accountModel()->count(), 1);
settings.clear();
}
void LogosWalletProviderTest::controllerCreationDoesNotWaitForWalletSync()
{
const QString settingsApplication = QStringLiteral("WalletAsyncCreationTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
provider.snapshotResult.accounts = {
{ ACCOUNT_A, QStringLiteral("5"), true },
};
WalletController controller(provider, settingsApplication);
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QCOMPARE(provider.createWalletCalls, 1);
QCOMPARE(provider.snapshotCalls, 0);
QVERIFY(controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing"));
QVERIFY(!controller.state().canSubmit());
QCOMPARE(controller.accountModel()->count(), 0);
QTRY_COMPARE(provider.snapshotCalls, 1);
QVERIFY(provider.lastForceRefresh);
QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing"));
provider.finishSnapshot();
QCOMPARE(controller.state().syncStatus, QStringLiteral("ready"));
QVERIFY(controller.state().canSubmit());
QCOMPARE(controller.accountModel()->count(), 1);
settings.clear();
}
void LogosWalletProviderTest::controllerSeedsDefaultWalletConfigWithConfiguredEndpoint()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString walletHome = directory.filePath(QStringLiteral("wallet"));
ScopedEnvironment walletHomeEnvironment(
QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit());
const QString settingsApplication = QStringLiteral("WalletDefaultEndpointTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
WalletController controller(provider, settingsApplication);
controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/"));
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
const QString configPath = walletHome + QStringLiteral("/wallet_config.json");
QCOMPARE(provider.lastPaths.config, configPath);
QFile config(configPath);
QVERIFY(config.open(QIODevice::ReadOnly));
const QJsonDocument document = QJsonDocument::fromJson(config.readAll());
QVERIFY(document.isObject());
const QJsonObject values = document.object();
QCOMPARE(values.value(QStringLiteral("sequencer_addr")).toString(),
QStringLiteral("https://testnet.lez.logos.co/"));
QCOMPARE(values.value(QStringLiteral("seq_poll_timeout")).toString(),
QStringLiteral("12s"));
QCOMPARE(values.value(QStringLiteral("seq_tx_poll_max_blocks")).toInt(), 5);
QCOMPARE(values.value(QStringLiteral("seq_poll_max_retries")).toInt(), 5);
QCOMPARE(values.value(QStringLiteral("seq_block_poll_max_amount")).toInt(), 100);
settings.clear();
}
void LogosWalletProviderTest::controllerPreservesExistingDefaultWalletConfig()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString walletHome = directory.filePath(QStringLiteral("wallet"));
QVERIFY(QDir().mkpath(walletHome));
const QString configPath = walletHome + QStringLiteral("/wallet_config.json");
const QByteArray existingConfig = QByteArrayLiteral(
"{\"sequencer_addr\":\"http://127.0.0.1:3040/\",\"custom\":true}");
QFile config(configPath);
QVERIFY(config.open(QIODevice::WriteOnly));
QCOMPARE(config.write(existingConfig), qint64(existingConfig.size()));
config.close();
ScopedEnvironment walletHomeEnvironment(
QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit());
const QString settingsApplication = QStringLiteral("WalletExistingEndpointTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
WalletController controller(provider, settingsApplication);
controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/"));
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QVERIFY(config.open(QIODevice::ReadOnly));
QCOMPARE(config.readAll(), existingConfig);
settings.clear();
}
void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityTest");
@@ -434,19 +880,164 @@ void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect()
QVERIFY(controller.open());
QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000);
controller.disconnect();
finished.clear();
auto* timer = controller.findChild<QTimer*>();
QVERIFY(timer);
QVERIFY(timer->isActive());
controller.disconnect();
QVERIFY(!timer->isActive());
finished.clear();
timer->setInterval(1);
controller.start();
QTest::qWait(50);
QVERIFY(!timer->isActive());
QCOMPARE(finished.count(), 0);
settings.clear();
}
void LogosWalletProviderTest::completedAsyncSnapshotReleasesCallback()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
bool completed = false;
std::weak_ptr<int> callbackLifetime;
{
auto lifetime = std::make_shared<int>(1);
callbackLifetime = lifetime;
provider.snapshotAsync(true,
[lifetime = std::move(lifetime), &completed](WalletSnapshot snapshot) {
QVERIFY(snapshot.ok());
completed = true;
});
}
QVERIFY(completed);
QVERIFY(callbackLifetime.expired());
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
}
void LogosWalletProviderTest::deferredCallbacksIgnoreDestroyedController()
{
const QString settingsApplication = QStringLiteral("WalletDestroyedCallbackTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
{
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
QVERIFY(controller->open());
}
provider.finishConnect();
provider.deferAsync = false;
{
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
QVERIFY(controller->open());
provider.deferAsync = true;
controller->refresh();
}
provider.finishSnapshot();
settings.clear();
}
void LogosWalletProviderTest::newerReachabilityResultWins()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityOrderTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
QTcpServer firstServer;
QTcpServer secondServer;
QVERIFY(firstServer.listen(QHostAddress::LocalHost));
QVERIFY(secondServer.listen(QHostAddress::LocalHost));
const QString firstEndpoint = QStringLiteral("http://127.0.0.1:%1")
.arg(firstServer.serverPort());
const QString secondEndpoint = QStringLiteral("http://127.0.0.1:%1")
.arg(secondServer.serverPort());
FakeWalletProvider provider;
provider.connectResult.snapshot.sequencerAddress = firstEndpoint;
WalletController controller(provider, settingsApplication);
auto* network = controller.findChild<QNetworkAccessManager*>();
QVERIFY(network);
QSignalSpy finished(network, &QNetworkAccessManager::finished);
QVERIFY(controller.open());
QTRY_VERIFY(firstServer.hasPendingConnections());
QTcpSocket* first = firstServer.nextPendingConnection();
QVERIFY(first);
provider.createAccountResult.accountId = ACCOUNT_B;
provider.createAccountResult.snapshot.sequencerAddress = secondEndpoint;
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
QTRY_VERIFY(secondServer.hasPendingConnections());
QTcpSocket* second = secondServer.nextPendingConnection();
QVERIFY(second);
second->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
second->disconnectFromHost();
QTRY_COMPARE(finished.size(), 1);
QVERIFY(controller.state().sequencerReachable);
first->disconnectFromHost();
QTRY_COMPARE(finished.size(), 2);
QVERIFY(controller.state().sequencerReachable);
settings.clear();
}
void LogosWalletProviderTest::coalescesReachabilityChecksForSameEndpoint()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityCoalesceTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
QTcpServer server;
QVERIFY(server.listen(QHostAddress::LocalHost));
const QString endpoint = QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort());
FakeWalletProvider provider;
provider.connectResult.snapshot.sequencerAddress = endpoint;
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QTRY_VERIFY(server.hasPendingConnections());
QTcpSocket* request = server.nextPendingConnection();
QVERIFY(request);
provider.createAccountResult.accountId = ACCOUNT_B;
provider.createAccountResult.snapshot.sequencerAddress = endpoint;
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
QTest::qWait(50);
QVERIFY(!server.hasPendingConnections());
request->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
request->disconnectFromHost();
settings.clear();
}
void LogosWalletProviderTest::controllerReportsPartialWalletCreation()
{
const QString settingsApplication = QStringLiteral("WalletPartialCreationTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
provider.createWalletResult.failure = WalletFailure::SaveFailed;
WalletController controller(provider, settingsApplication);
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QVERIFY(!controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("error"));
QCOMPARE(controller.state().syncError, QStringLiteral("save_failed"));
settings.clear();
}
QTEST_GUILESS_MAIN(LogosWalletProviderTest)
#include "LogosWalletProviderTest.moc"
@@ -0,0 +1,328 @@
#include "SequencerIdentityProbe.h"
#include <QHostAddress>
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QList>
#include <QPointer>
#include <QQueue>
#include <QSignalSpy>
#include <QTcpServer>
#include <QTcpSocket>
#include <QUrl>
#include <QtTest>
#include <utility>
namespace {
const QString IDENTITY(64, QLatin1Char('a'));
const QString OTHER_IDENTITY(64, QLatin1Char('b'));
SequencerNetworkContext::Configuration networkConfiguration()
{
return {
QStringLiteral("testnet"),
IDENTITY,
QStringLiteral("checkpoint:"),
};
}
QByteArray jsonRpcResult(const QString& identity)
{
return QJsonDocument(QJsonObject {
{ QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
{ QStringLiteral("id"), 1 },
{ QStringLiteral("result"), identity },
}).toJson(QJsonDocument::Compact);
}
class RpcServer final : public QObject {
public:
RpcServer()
{
m_server.listen(QHostAddress::LocalHost);
connect(&m_server, &QTcpServer::newConnection, this, [this]() {
while (QTcpSocket* socket = m_server.nextPendingConnection())
attach(socket);
});
}
QUrl endpoint() const
{
return QUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()));
}
bool isListening() const { return m_server.isListening(); }
void enqueueResponse(int status, QByteArray body)
{
m_responses.enqueue({ status, std::move(body) });
}
void holdNextResponse()
{
m_responses.enqueue({ 0, {} });
}
void respondHeld(int status, QByteArray body)
{
while (!m_held.isEmpty()) {
const QPointer<QTcpSocket> socket = m_held.dequeue();
if (socket)
sendResponse(socket, status, std::move(body));
return;
}
}
int requestCount() const { return m_requests.size(); }
QByteArray lastRequest() const { return m_requests.isEmpty() ? QByteArray() : m_requests.last(); }
private:
struct Response {
int status;
QByteArray body;
};
void attach(QTcpSocket* socket)
{
socket->setParent(this);
connect(socket, &QTcpSocket::readyRead, this, [this, socket]() {
QByteArray& request = m_partialRequests[socket];
request.append(socket->readAll());
const qsizetype headerEnd = request.indexOf("\r\n\r\n");
if (headerEnd < 0)
return;
const QByteArray headers = request.left(headerEnd);
qsizetype contentLength = 0;
for (const QByteArray& line : headers.split('\n')) {
const qsizetype separator = line.indexOf(':');
if (separator < 0)
continue;
if (line.left(separator).trimmed().compare("content-length",
Qt::CaseInsensitive) == 0) {
contentLength = line.mid(separator + 1).trimmed().toLongLong();
break;
}
}
if (request.size() < headerEnd + 4 + contentLength)
return;
m_requests.append(request);
m_partialRequests.remove(socket);
if (m_responses.isEmpty()) {
m_held.enqueue(socket);
return;
}
const Response response = m_responses.dequeue();
if (response.status == 0) {
m_held.enqueue(socket);
return;
}
sendResponse(socket, response.status, response.body);
});
connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater);
}
static void sendResponse(QTcpSocket* socket, int status, QByteArray body)
{
const QByteArray statusText = status >= 200 && status < 300
? QByteArrayLiteral("OK") : QByteArrayLiteral("Service Unavailable");
QByteArray response = QByteArrayLiteral("HTTP/1.1 ") + QByteArray::number(status)
+ QByteArrayLiteral(" ") + statusText
+ QByteArrayLiteral("\r\nContent-Type: application/json\r\nContent-Length: ")
+ QByteArray::number(body.size())
+ QByteArrayLiteral("\r\nConnection: close\r\n\r\n") + body;
socket->write(response);
socket->flush();
socket->disconnectFromHost();
}
QTcpServer m_server;
QHash<QTcpSocket*, QByteArray> m_partialRequests;
QQueue<Response> m_responses;
QQueue<QPointer<QTcpSocket>> m_held;
QList<QByteArray> m_requests;
};
SequencerIdentityProbe::Request requestFor(const QUrl& endpoint)
{
return {
endpoint,
QStringLiteral("getChannelId"),
QJsonArray { 10 },
SequencerIdentityProbe::stringIdentity,
};
}
}
class SequencerIdentityProbeTest final : public QObject {
Q_OBJECT
private slots:
void sendsConfiguredRequestAndAcceptsIdentity();
void waitsForEndpointBeforeProbing();
void retriesRejectedResponses_data();
void retriesRejectedResponses();
void supersedesEndpointReply();
void abortsReplyWhenReachabilityIsLost();
void retriesTransportFailureAfterEndpointChanges();
void extractsCheckpointBlockHash();
};
void SequencerIdentityProbeTest::sendsConfiguredRequestAndAcceptsIdentity()
{
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
QCOMPARE(probe.snapshot().fingerprint, QStringLiteral("checkpoint:") + IDENTITY);
QCOMPARE(server.requestCount(), 1);
QVERIFY(server.lastRequest().contains("\"method\":\"getChannelId\""));
QVERIFY(server.lastRequest().contains("\"params\":[10]"));
}
void SequencerIdentityProbeTest::waitsForEndpointBeforeProbing()
{
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(QUrl())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QCOMPARE(probe.snapshot().status, QStringLiteral("network_unknown"));
QCOMPARE(server.requestCount(), 0);
QVERIFY(probe.setEndpoint(server.endpoint()));
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
QCOMPARE(server.requestCount(), 1);
}
void SequencerIdentityProbeTest::retriesRejectedResponses_data()
{
QTest::addColumn<int>("status");
QTest::addColumn<QByteArray>("body");
QTest::addColumn<QString>("failure");
QTest::newRow("http") << 503 << QByteArrayLiteral("{}")
<< QStringLiteral("http_status");
QTest::newRow("malformed") << 200 << QByteArrayLiteral("not-json")
<< QStringLiteral("malformed_response");
QTest::newRow("json-rpc-error") << 200
<< QByteArrayLiteral("{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-1}}")
<< QStringLiteral("json_rpc_error");
}
void SequencerIdentityProbeTest::retriesRejectedResponses()
{
QFETCH(int, status);
QFETCH(QByteArray, body);
QFETCH(QString, failure);
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(status, body);
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QSignalSpy failures(&probe, &SequencerIdentityProbe::probeFailed);
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(server.requestCount(), 2);
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
QVERIFY(!failures.isEmpty());
QCOMPARE(failures.first().at(0).toString(), failure);
}
void SequencerIdentityProbeTest::supersedesEndpointReply()
{
RpcServer first;
QVERIFY(first.isListening());
first.holdNextResponse();
RpcServer second;
QVERIFY(second.isListening());
second.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(first.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(first.requestCount(), 1);
QVERIFY(probe.setEndpoint(second.endpoint()));
QTRY_COMPARE(second.requestCount(), 1);
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
first.respondHeld(200, jsonRpcResult(OTHER_IDENTITY));
QTest::qWait(50);
QCOMPARE(probe.snapshot().status, QStringLiteral("ready"));
}
void SequencerIdentityProbeTest::abortsReplyWhenReachabilityIsLost()
{
RpcServer server;
QVERIFY(server.isListening());
server.holdNextResponse();
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(server.requestCount(), 1);
probe.setReachable(false);
server.respondHeld(200, jsonRpcResult(IDENTITY));
QTest::qWait(50);
QCOMPARE(probe.snapshot().status, QStringLiteral("network_unknown"));
}
void SequencerIdentityProbeTest::retriesTransportFailureAfterEndpointChanges()
{
QTcpServer unavailable;
QVERIFY(unavailable.listen(QHostAddress::LocalHost));
const QUrl unavailableEndpoint(
QStringLiteral("http://127.0.0.1:%1").arg(unavailable.serverPort()));
unavailable.close();
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QSignalSpy failures(&probe, &SequencerIdentityProbe::probeFailed);
QVERIFY(probe.configure(networkConfiguration(), requestFor(unavailableEndpoint)));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_VERIFY(!failures.isEmpty());
QCOMPARE(failures.first().at(0).toString(), QStringLiteral("transport_error"));
QVERIFY(probe.setEndpoint(server.endpoint()));
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
}
void SequencerIdentityProbeTest::extractsCheckpointBlockHash()
{
QByteArray block(72, '\0');
const QByteArray expected(32, static_cast<char>(0xab));
block.replace(40, expected.size(), expected);
QCOMPARE(SequencerIdentityProbe::checkpointBlockHash(
QJsonValue(QString::fromLatin1(block.toBase64()))),
QString::fromLatin1(expected.toHex()));
QVERIFY(SequencerIdentityProbe::checkpointBlockHash(QJsonValue(QStringLiteral("bad"))).isEmpty());
}
QTEST_MAIN(SequencerIdentityProbeTest)
#include "SequencerIdentityProbeTest.moc"
@@ -0,0 +1,96 @@
#include <QtTest>
#include "SequencerNetworkContext.h"
namespace {
const QString NETWORK_ID = QStringLiteral("testnet");
const QString IDENTITY(64, QLatin1Char('a'));
SequencerNetworkContext::Configuration configuration()
{
return {
NETWORK_ID,
IDENTITY,
QStringLiteral("checkpoint:"),
};
}
}
class SequencerNetworkContextTest final : public QObject {
Q_OBJECT
private slots:
void acceptsMatchingIdentity();
void rejectsLateReplyAfterReachabilityLoss();
void rejectsSupersededProbe();
void rejectsInvalidConfiguration();
};
void SequencerNetworkContextTest::acceptsMatchingIdentity()
{
SequencerNetworkContext context;
QVERIFY(context.configure(configuration()));
context.setSequencerAvailable(true);
context.setReachable(true);
const std::optional<quint64> probe = context.beginIdentityProbe();
QVERIFY(probe.has_value());
QVERIFY(context.finishIdentityProbe(*probe, IDENTITY));
QCOMPARE(context.snapshot().id, NETWORK_ID);
QCOMPARE(context.snapshot().status, QStringLiteral("ready"));
QCOMPARE(context.snapshot().fingerprint, QStringLiteral("checkpoint:") + IDENTITY);
}
void SequencerNetworkContextTest::rejectsLateReplyAfterReachabilityLoss()
{
SequencerNetworkContext context;
QVERIFY(context.configure(configuration()));
context.setSequencerAvailable(true);
context.setReachable(true);
const std::optional<quint64> probe = context.beginIdentityProbe();
QVERIFY(probe.has_value());
context.setReachable(false);
QVERIFY(!context.finishIdentityProbe(*probe, IDENTITY));
QCOMPARE(context.snapshot().status, QStringLiteral("network_unknown"));
QVERIFY(context.snapshot().fingerprint.isEmpty());
}
void SequencerNetworkContextTest::rejectsSupersededProbe()
{
SequencerNetworkContext context;
QVERIFY(context.configure(configuration()));
context.setSequencerAvailable(true);
context.setReachable(true);
const std::optional<quint64> firstProbe = context.beginIdentityProbe();
QVERIFY(firstProbe.has_value());
context.setReachable(false);
context.setReachable(true);
const std::optional<quint64> secondProbe = context.beginIdentityProbe();
QVERIFY(secondProbe.has_value());
QVERIFY(!context.finishIdentityProbe(*firstProbe, IDENTITY));
QVERIFY(context.finishIdentityProbe(*secondProbe, IDENTITY));
QCOMPARE(context.snapshot().status, QStringLiteral("ready"));
}
void SequencerNetworkContextTest::rejectsInvalidConfiguration()
{
SequencerNetworkContext context;
SequencerNetworkContext::Configuration invalid = configuration();
invalid.expectedIdentity = QString(64, QLatin1Char('A'));
QVERIFY(!context.configure(invalid));
QVERIFY(!context.isConfigured());
QCOMPARE(context.snapshot().id, NETWORK_ID);
QCOMPARE(context.snapshot().status, QStringLiteral("config_missing"));
}
QTEST_MAIN(SequencerNetworkContextTest)
#include "SequencerNetworkContextTest.moc"
@@ -0,0 +1,66 @@
#include "SequencerNetworkSettings.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryFile>
#include <QtTest>
class SequencerNetworkSettingsTest : public QObject {
Q_OBJECT
private slots:
void loadsBundledTestnetIdentity();
void loadsDevnetChannelIdentity();
void rejectsInvalidDevnetIdentity();
};
void SequencerNetworkSettingsTest::loadsBundledTestnetIdentity()
{
const auto settings = SequencerNetworkSettingsLoader::load(
QStringLiteral("testnet"), {});
QVERIFY(settings);
QCOMPARE(settings->context.id, QStringLiteral("testnet"));
QCOMPARE(settings->context.expectedIdentity,
QStringLiteral("0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a"));
QCOMPARE(settings->context.fingerprintPrefix, QStringLiteral("block10:"));
QCOMPARE(settings->identityMethod, SequencerIdentityMethod::CheckpointBlock);
}
void SequencerNetworkSettingsTest::loadsDevnetChannelIdentity()
{
const QString identity(64, QLatin1Char('a'));
QTemporaryFile config;
QVERIFY(config.open());
const QByteArray contents = QJsonDocument(QJsonObject {
{ QStringLiteral("channelId"), identity },
}).toJson(QJsonDocument::Compact);
QCOMPARE(config.write(contents), qint64(contents.size()));
config.flush();
const auto settings = SequencerNetworkSettingsLoader::load(
QStringLiteral("devnet"), config.fileName());
QVERIFY(settings);
QCOMPARE(settings->context.id, QStringLiteral("devnet"));
QCOMPARE(settings->context.expectedIdentity, identity);
QCOMPARE(settings->context.fingerprintPrefix, QStringLiteral("channel:"));
QCOMPARE(settings->identityMethod, SequencerIdentityMethod::ChannelId);
}
void SequencerNetworkSettingsTest::rejectsInvalidDevnetIdentity()
{
QTemporaryFile config;
QVERIFY(config.open());
const QByteArray contents = QJsonDocument(QJsonObject {
{ QStringLiteral("channelId"), QStringLiteral("not-an-identity") },
}).toJson(QJsonDocument::Compact);
QCOMPARE(config.write(contents), qint64(contents.size()));
config.flush();
QVERIFY(!SequencerNetworkSettingsLoader::load(
QStringLiteral("devnet"), config.fileName()));
}
QTEST_GUILESS_MAIN(SequencerNetworkSettingsTest)
#include "SequencerNetworkSettingsTest.moc"
@@ -0,0 +1,22 @@
#include "WalletIdlDecoder.h"
#include <QtTest>
class WalletIdlDecoderLinkTest final : public QObject {
Q_OBJECT
private slots:
void linksDefaultDecoder();
};
void WalletIdlDecoderLinkTest::linksDefaultDecoder()
{
const WalletDecodeResult result = WalletIdlDecoder::decode(
QByteArrayLiteral("not-json"), {});
QCOMPARE(result.status, QStringLiteral("error"));
QCOMPARE(result.error, QStringLiteral("invalid_idl"));
}
QTEST_GUILESS_MAIN(WalletIdlDecoderLinkTest)
#include "WalletIdlDecoderLinkTest.moc"
@@ -0,0 +1,206 @@
#include "WalletPortfolioService.h"
#include <QtTest>
#include <utility>
// The service normally links this symbol from wallet-idl-decoder. Every test
// injects a decoder, so keep this target independent from the Rust FFI library.
WalletDecodeResult WalletIdlDecoder::decode(const QByteArray&,
const QVector<WalletAccountRead>&)
{
return {
QStringLiteral("error"),
QStringLiteral("unexpected_default_decoder"),
{},
};
}
namespace {
const QString DEFINITION_ID(64, QLatin1Char('a'));
const QString DEFINITION_BASE58 = QStringLiteral("base58-definition");
const QString HOLDING_ID(64, QLatin1Char('b'));
const QString TOKEN_PROGRAM_ID(64, QLatin1Char('c'));
const QString AMM_ACCOUNT_ID(64, QLatin1Char('d'));
const QString AMM_PROGRAM_ID(64, QLatin1Char('e'));
WalletAccountRead read(const QString& accountId,
const QString& programOwner,
const QString& data)
{
WalletAccountRead result;
result.accountId = accountId;
result.status = QStringLiteral("ok");
result.programOwner = programOwner;
result.dataHex = data;
return result;
}
WalletPortfolioRequest request(const QString& name = QStringLiteral("Test token"))
{
WalletSnapshot snapshot;
snapshot.publicAccountReads = {
read(DEFINITION_ID, TOKEN_PROGRAM_ID, QStringLiteral("definition")),
read(HOLDING_ID, TOKEN_PROGRAM_ID, QStringLiteral("holding")),
read(AMM_ACCOUNT_ID, AMM_PROGRAM_ID, QStringLiteral("amm")),
};
WalletPortfolioRequest result(snapshot);
result.tokenDefinitionIds = { DEFINITION_BASE58 };
result.tokens = { QVariantMap {
{ QStringLiteral("definitionId"), DEFINITION_BASE58 },
{ QStringLiteral("definitionIdHex"), DEFINITION_ID },
{ QStringLiteral("name"), name },
} };
result.tokenProgramId = TOKEN_PROGRAM_ID;
result.tokenIdl = QByteArrayLiteral("token-idl");
return result;
}
WalletDecodedAccount tokenDefinition()
{
WalletDecodedAccount account;
account.id = DEFINITION_ID;
account.status = QStringLiteral("decoded");
account.typeName = QStringLiteral("TokenDefinition");
account.value = QJsonObject {
{ QStringLiteral("Fungible"), QJsonObject {
{ QStringLiteral("name"), QStringLiteral("Test token") },
} },
};
return account;
}
WalletDecodedAccount tokenHolding(bool decoded = true)
{
WalletDecodedAccount account;
account.id = HOLDING_ID;
account.status = decoded ? QStringLiteral("decoded") : QStringLiteral("error");
account.typeName = QStringLiteral("TokenHolding");
account.value = QJsonObject {
{ QStringLiteral("Fungible"), QJsonObject {
{ QStringLiteral("definition_id"), QStringLiteral("definition") },
{ QStringLiteral("balance"), QStringLiteral("25") },
} },
};
account.accountIds.insert(QStringLiteral("definition"), DEFINITION_ID);
return account;
}
WalletDecodedAccount ammAccount()
{
WalletDecodedAccount account;
account.id = AMM_ACCOUNT_ID;
account.status = QStringLiteral("decoded");
account.typeName = QStringLiteral("Pool");
account.value = QJsonObject {
{ QStringLiteral("Pool"), QJsonObject {} },
};
return account;
}
WalletPortfolioService::Decoder decoder(int* calls, bool failHolding = false)
{
return [calls, failHolding](const QByteArray&, const QVector<WalletAccountRead>& reads) {
++*calls;
WalletDecodeResult result;
result.status = QStringLiteral("ok");
for (const WalletAccountRead& item : reads) {
if (item.accountId == DEFINITION_ID)
result.accounts.append(tokenDefinition());
else if (item.accountId == HOLDING_ID)
result.accounts.append(tokenHolding(!failHolding));
else if (item.accountId == AMM_ACCOUNT_ID)
result.accounts.append(ammAccount());
}
return result;
};
}
}
class WalletPortfolioServiceTest : public QObject {
Q_OBJECT
private slots:
void acceptsBase58DefinitionsAndReusesUnchangedDecodes();
void exposesHoldingDecodeFailureWithoutZeroBalance();
void exposesUnreadPublicAccountWithoutZeroBalance();
void exposesUnresolvedDefinitions();
};
void WalletPortfolioServiceTest::acceptsBase58DefinitionsAndReusesUnchangedDecodes()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls));
service.registerProgram(AMM_PROGRAM_ID, QStringLiteral("AMM"), QByteArrayLiteral("amm-idl"));
const WalletPortfolioResult first = service.refresh(request());
QCOMPARE(first.status, QStringLiteral("ready"));
QCOMPARE(first.assets.size(), 1);
const QVariantMap asset = first.assets.first().toMap();
QCOMPARE(asset.value(QStringLiteral("definitionId")).toString(), DEFINITION_ID);
QCOMPARE(asset.value(QStringLiteral("displayDefinitionId")).toString(), DEFINITION_BASE58);
QCOMPARE(asset.value(QStringLiteral("balance")).toString(), QStringLiteral("25"));
QCOMPARE(first.presentations.size(), 3);
const int firstDecodeCalls = decodeCalls;
QVERIFY(firstDecodeCalls > 0);
const WalletPortfolioResult renamed = service.refresh(request(QStringLiteral("Renamed")));
QCOMPARE(decodeCalls, firstDecodeCalls);
QCOMPARE(renamed.status, QStringLiteral("ready"));
QCOMPARE(renamed.assets.first().toMap().value(QStringLiteral("name")).toString(),
QStringLiteral("Renamed"));
}
void WalletPortfolioServiceTest::exposesHoldingDecodeFailureWithoutZeroBalance()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls, true));
const WalletPortfolioResult result = service.refresh(request());
QCOMPARE(result.status, QStringLiteral("partial"));
QCOMPARE(result.error, QStringLiteral("holding_decode_failed"));
const QVariantMap asset = result.assets.first().toMap();
QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("unavailable"));
QVERIFY(asset.value(QStringLiteral("balance")).toString().isEmpty());
}
void WalletPortfolioServiceTest::exposesUnreadPublicAccountWithoutZeroBalance()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls));
WalletPortfolioRequest input = request();
for (WalletAccountRead& account : input.publicAccountReads) {
if (account.accountId == HOLDING_ID)
account = WalletAccountRead { HOLDING_ID };
}
const WalletPortfolioResult result = service.refresh(input);
QCOMPARE(result.status, QStringLiteral("partial"));
QCOMPARE(result.error, QStringLiteral("public_account_read_failed"));
const QVariantMap asset = result.assets.first().toMap();
QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("unavailable"));
QVERIFY(asset.value(QStringLiteral("balance")).toString().isEmpty());
}
void WalletPortfolioServiceTest::exposesUnresolvedDefinitions()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls));
WalletPortfolioRequest input = request();
input.tokens.clear();
const WalletPortfolioResult result = service.refresh(input);
QCOMPARE(result.status, QStringLiteral("error"));
QCOMPARE(result.error, QStringLiteral("definitions_unavailable"));
QCOMPARE(result.assets.size(), 1);
QCOMPARE(result.assets.first().toMap().value(QStringLiteral("status")).toString(),
QStringLiteral("unavailable"));
}
QTEST_GUILESS_MAIN(WalletPortfolioServiceTest)
#include "WalletPortfolioServiceTest.moc"
@@ -6,6 +6,8 @@
#include <QVariant>
#include <QVariantList>
#include <functional>
class LogosAPI;
class FakeExecutionZone {
@@ -48,6 +50,13 @@ public:
return openResult;
}
void openAsync(const QString& config,
const QString& storage,
std::function<void(int)> callback)
{
callback(open(config, storage));
}
QString create_new(const QString& config,
const QString& storage,
const QString& password)
@@ -66,34 +75,69 @@ public:
QString create_account_public() { return publicAccountId; }
QString create_account_private() { return privateAccountId; }
QString account_id_to_base58(const QString& accountId) const
{
return QStringLiteral("base58-") + accountId;
}
int get_last_synced_block() const { return lastSyncedBlock; }
int get_current_block_height() const { return currentBlockHeight; }
void get_last_synced_blockAsync(std::function<void(int)> callback)
{
callback(get_last_synced_block());
}
void get_current_block_heightAsync(std::function<void(int)> callback)
{
callback(get_current_block_height());
}
int sync_to_block(quint64)
{
++syncCalls;
return syncResult;
}
void sync_to_blockAsync(int blockId, std::function<void(int)> callback)
{
callback(sync_to_block(static_cast<quint64>(blockId)));
}
QString get_sequencer_addr() const { return sequencerAddress; }
void get_sequencer_addrAsync(std::function<void(QString)> callback)
{
callback(get_sequencer_addr());
}
QVariantList list_accounts()
{
++listCalls;
return accounts;
}
void list_accountsAsync(std::function<void(QVariantList)> callback)
{
callback(list_accounts());
}
QString get_account_public(const QString& accountId)
{
++publicReadCalls;
return publicAccounts.value(accountId);
}
void get_account_publicAsync(const QString& accountId,
std::function<void(QString)> callback)
{
callback(get_account_public(accountId));
}
QString get_balance(const QString& accountId, bool) const
{
return balances.value(accountId);
}
void get_balanceAsync(const QString& accountId,
bool isPublic,
std::function<void(QString)> callback)
{
callback(get_balance(accountId, isPublic));
}
QString send_generic_public_transaction(
const QStringList& accountIds,
@@ -108,6 +152,7 @@ public:
submittedProgramId = programId;
return transactionResponse;
}
};
struct LogosModules {