From 93268696cd01ed0c20dbdd447e84036f92934d4f Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 20:18:09 -0300 Subject: [PATCH 1/2] feat(wallet): humanize shared wallet experience --- Cargo.lock | 11 + Cargo.toml | 1 + apps/amm/CMakeLists.txt | 88 +++ apps/amm/config/networks.json | 18 + apps/amm/flake.nix | 6 + apps/amm/metadata.json | 3 +- apps/amm/src/ActiveNetwork.cpp | 139 ++++ apps/amm/src/ActiveNetwork.h | 24 + apps/amm/src/AmmUiBackend.cpp | 462 ++++++++++- apps/amm/src/AmmUiBackend.h | 44 +- apps/amm/src/AmmUiBackend.rep | 14 + apps/amm/src/TokenDefinitionCache.cpp | 104 +++ apps/amm/src/TokenDefinitionCache.h | 53 ++ apps/amm/src/WalletIdlDecoder.cpp | 100 +++ apps/amm/src/WalletIdlDecoder.h | 54 ++ apps/amm/tests/cpp/ActiveNetworkTest.cpp | 47 ++ .../cpp/AmmUiBackendDefinitionCacheTest.cpp | 242 ++++++ .../tests/cpp/TokenDefinitionCacheTest.cpp | 185 +++++ apps/shared/wallet/CMakeLists.txt | 16 +- .../qml/TransactionConfirmationDialog.qml | 116 ++- apps/shared/wallet/qml/WalletControl.qml | 734 ++++++++++++++---- .../wallet/qml/internal/AccountDelegate.qml | 103 ++- .../shared/wallet/qml/internal/CopyButton.qml | 21 +- .../qml/internal/CreateAccountDialog.qml | 6 +- .../qml/internal/CreateWalletDialog.qml | 1 + .../qml/internal/WalletMessageDialog.qml | 5 + .../shared/wallet/src/LogosWalletProvider.cpp | 711 +++++++++++++++-- apps/shared/wallet/src/LogosWalletProvider.h | 14 +- apps/shared/wallet/src/WalletAccountId.cpp | 112 +++ apps/shared/wallet/src/WalletAccountId.h | 6 + apps/shared/wallet/src/WalletAccountModel.cpp | 245 +++++- apps/shared/wallet/src/WalletAccountModel.h | 51 +- apps/shared/wallet/src/WalletController.cpp | 393 +++++++++- apps/shared/wallet/src/WalletController.h | 36 + apps/shared/wallet/src/WalletProvider.h | 17 + .../tests/cpp/LogosWalletProviderTest.cpp | 670 +++++++++++++++- .../wallet/tests/cpp/fixtures/logos_sdk.h | 90 +++ .../wallet/tests/qml/tst_CopyButton.qml | 56 ++ .../qml/tst_TransactionConfirmationDialog.qml | 32 + .../wallet/tests/qml/tst_WalletControl.qml | 398 +++++++++- .../wallet/tests/support/FakeWalletProvider.h | 95 +++ flake.nix | 27 +- tools/wallet-idl-decoder/Cargo.toml | 18 + .../include/wallet_idl_decoder.h | 15 + tools/wallet-idl-decoder/src/lib.rs | 278 +++++++ 45 files changed, 5497 insertions(+), 364 deletions(-) create mode 100644 apps/amm/config/networks.json create mode 100644 apps/amm/src/ActiveNetwork.cpp create mode 100644 apps/amm/src/TokenDefinitionCache.cpp create mode 100644 apps/amm/src/TokenDefinitionCache.h create mode 100644 apps/amm/src/WalletIdlDecoder.cpp create mode 100644 apps/amm/src/WalletIdlDecoder.h create mode 100644 apps/amm/tests/cpp/ActiveNetworkTest.cpp create mode 100644 apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp create mode 100644 apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp create mode 100644 apps/shared/wallet/src/WalletAccountId.cpp create mode 100644 apps/shared/wallet/src/WalletAccountId.h create mode 100644 apps/shared/wallet/tests/qml/tst_CopyButton.qml create mode 100644 tools/wallet-idl-decoder/Cargo.toml create mode 100644 tools/wallet-idl-decoder/include/wallet_idl_decoder.h create mode 100644 tools/wallet-idl-decoder/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0147201a..96c13945 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4515,6 +4515,17 @@ dependencies = [ "libc", ] +[[package]] +name = "wallet-idl-decoder" +version = "0.1.0" +dependencies = [ + "base58", + "hex", + "serde", + "serde_json", + "spel-framework-core", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 35a1dee8..fe725e31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "programs/integration_tests", "tools/idl-gen", "tools/risc0-packager", + "tools/wallet-idl-decoder", ] exclude = [ "programs/token/methods/guest", diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 7f0121c1..c9b01506 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -37,24 +37,62 @@ logos_module( src/AmmUiBackend.h src/AmmUiBackend.cpp src/ActiveNetwork.h + src/ActiveNetwork.cpp src/AmmClient.h src/AmmClient.cpp src/NewPositionRuntime.h src/NewPositionRuntime.cpp src/SwapRuntime.h src/SwapRuntime.cpp + src/TokenDefinitionCache.h + src/TokenDefinitionCache.cpp + src/WalletIdlDecoder.h + src/WalletIdlDecoder.cpp FIND_PACKAGES Qt6Gui LINK_LIBRARIES Qt6::Gui + Qt6::Network PkgConfig::BASE58 LINK_TARGETS logos_wallet_access EXTERNAL_LIBS amm_client + wallet_idl_decoder +) + +set(LEZ_IDL_ARTIFACTS_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/../../artifacts" + CACHE PATH "Path to committed LEZ program IDL artifacts" +) +set(AMM_TOKEN_IDL "${LEZ_IDL_ARTIFACTS_DIR}/token-idl.json") +set(AMM_IDL "${LEZ_IDL_ARTIFACTS_DIR}/amm-idl.json") + +foreach(idl_file IN ITEMS "${AMM_TOKEN_IDL}" "${AMM_IDL}") + if(NOT EXISTS "${idl_file}") + message(FATAL_ERROR "Committed IDL artifact not found: ${idl_file}") + endif() +endforeach() + +set_source_files_properties( + "${AMM_TOKEN_IDL}" + PROPERTIES QT_RESOURCE_ALIAS "idl/token-idl.json" +) +set_source_files_properties( + "${AMM_IDL}" + PROPERTIES QT_RESOURCE_ALIAS "idl/amm-idl.json" +) +qt_add_resources(amm_ui_module_plugin amm_ui_wallet_data + PREFIX "/amm" + FILES + config/networks.json + "${AMM_TOKEN_IDL}" + "${AMM_IDL}" ) if(BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Test) + add_executable(amm_new_position_runtime_test tests/cpp/NewPositionRuntimeTest.cpp src/NewPositionRuntime.cpp @@ -66,4 +104,54 @@ if(BUILD_TESTING) logos_wallet_access ) add_test(NAME amm_new_position_runtime COMMAND amm_new_position_runtime_test) + + add_executable(amm_active_network_test + tests/cpp/ActiveNetworkTest.cpp + src/ActiveNetwork.cpp + ) + set_target_properties(amm_active_network_test PROPERTIES AUTOMOC ON) + target_include_directories(amm_active_network_test PRIVATE src) + target_link_libraries(amm_active_network_test PRIVATE Qt6::Core Qt6::Test) + add_test(NAME amm_active_network COMMAND amm_active_network_test) + + add_executable(amm_token_definition_cache_test + tests/cpp/TokenDefinitionCacheTest.cpp + src/TokenDefinitionCache.h + src/TokenDefinitionCache.cpp + ) + set_target_properties(amm_token_definition_cache_test PROPERTIES AUTOMOC ON) + target_compile_features(amm_token_definition_cache_test PRIVATE cxx_std_17) + target_include_directories(amm_token_definition_cache_test PRIVATE + src + "${LOGOS_WALLET_SOURCE_DIR}/src" + "${LOGOS_WALLET_SOURCE_DIR}/tests/support" + ) + target_link_libraries(amm_token_definition_cache_test PRIVATE Qt6::Core Qt6::Test) + add_test(NAME amm_token_definition_cache COMMAND amm_token_definition_cache_test) + + add_executable(amm_backend_definition_cache_test + tests/cpp/AmmUiBackendDefinitionCacheTest.cpp + ) + add_dependencies(amm_backend_definition_cache_test amm_ui_module_plugin) + set_target_properties(amm_backend_definition_cache_test PROPERTIES AUTOMOC ON) + target_compile_features(amm_backend_definition_cache_test PRIVATE cxx_std_17) + target_include_directories(amm_backend_definition_cache_test PRIVATE + src + "${CMAKE_CURRENT_BINARY_DIR}" + "${LOGOS_WALLET_SOURCE_DIR}/src" + "${LOGOS_WALLET_SOURCE_DIR}/tests/support" + ) + target_link_libraries(amm_backend_definition_cache_test PRIVATE + Qt6::Core + Qt6::Network + Qt6::RemoteObjects + Qt6::Test + amm_ui_module_plugin + ) + if(UNIX AND NOT APPLE) + target_link_options(amm_backend_definition_cache_test PRIVATE + "-Wl,--allow-shlib-undefined" + ) + endif() + add_test(NAME amm_backend_definition_cache COMMAND amm_backend_definition_cache_test) endif() diff --git a/apps/amm/config/networks.json b/apps/amm/config/networks.json new file mode 100644 index 00000000..75e03eba --- /dev/null +++ b/apps/amm/config/networks.json @@ -0,0 +1,18 @@ +{ + "testnet": { + "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a", + "ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07", + "tokenDefinitionIds": [ + "7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6", + "48c81cf032e601ca367fc9816b957dbf5c0e4c11cf7008e8f4581ec1a67aab42", + "159caef810ea545951b3bd913efe625ee45008c80865c330e72a72ed48b61649", + "75f33110b185717209e3955f228d4a4448801d0ce8ba438a4a268050eeff3f44", + "fbd107ca4bb66bc58f59ac2d32a759be3ee0fb453f8fecd1991c11837d9660c7", + "5547fcb72644d95a385d313b887a96be41ff263bce6150b49fd87276839822bf", + "fa43e74a97d79c5f907ff3edabda5ad89bfbd3b0922572e675d4ad3c7b6029c7", + "4f3231d8a01e1d79f163bc27fce0c860a4a2f6890280e9d135eafbde0d68ed79", + "fa32f354408857006f8ea396b0419823bd04436eadb2d273d2618a46b4793ed8", + "00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb" + ] + } +} diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 6b784955..025ec2e8 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -3,6 +3,8 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11-small"; + crane.url = "github:ipetkov/crane/v0.23.4"; # Shared C++ wallet access and Logos.Wallet QML sources. shared_wallet = { @@ -54,6 +56,10 @@ input = inputs.amm_client; packages.default = "amm_client"; }; + wallet_idl_decoder = { + input = inputs.amm_client; + packages.default = "wallet_idl_decoder"; + }; }; postInstall = '' # The builder installs the view under lib/qml after this hook. Its diff --git a/apps/amm/metadata.json b/apps/amm/metadata.json index 7b123c9c..6ae6cb0a 100644 --- a/apps/amm/metadata.json +++ b/apps/amm/metadata.json @@ -15,7 +15,8 @@ "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"] }, "external_libraries": [ - { "name": "amm_client" } + { "name": "amm_client" }, + { "name": "wallet_idl_decoder" } ], "cmake": { "find_packages": [], diff --git a/apps/amm/src/ActiveNetwork.cpp b/apps/amm/src/ActiveNetwork.cpp new file mode 100644 index 00000000..9bb1e275 --- /dev/null +++ b/apps/amm/src/ActiveNetwork.cpp @@ -0,0 +1,139 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include + +namespace { +const char NETWORK_ENV[] = "AMM_UI_NETWORK"; +const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE"; + +bool isLowerHex(const QString& value, int size) +{ + if (value.size() != size) + return false; + for (const QChar character : value) { + const bool digit = character >= QLatin1Char('0') + && character <= QLatin1Char('9'); + if (!digit && (character < QLatin1Char('a') || character > QLatin1Char('f'))) + return false; + } + return true; +} +} + +bool ActiveNetwork::load() +{ + m_network = {}; + m_network.status = QStringLiteral("config_missing"); + m_expectedIdentity.clear(); + const QByteArray selected = qgetenv(NETWORK_ENV); + m_network.id = selected.isEmpty() + ? QStringLiteral("testnet") + : QString::fromLocal8Bit(selected).trimmed(); + + QJsonObject entry; + if (isDevnet()) { + const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV)); + QFile file(path); + if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + entry = document.object(); + m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString(); + } else { + QFile file(QStringLiteral(":/amm/config/networks.json")); + if (!file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + entry = document.object().value(m_network.id).toObject(); + m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString(); + } + + m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString(); + if (!isValidIdentity(m_expectedIdentity) + || !isLowerHex(m_network.ammProgramId, 64)) { + return false; + } + for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) { + const QString id = value.toString(); + if (!isLowerHex(id, 64)) { + m_network.tokenIds.clear(); + return false; + } + m_network.tokenIds.append(id); + } + if (m_network.tokenIds.isEmpty()) + return false; + m_network.status = QStringLiteral("network_unknown"); + return true; +} + +bool ActiveNetwork::isConfigured() const +{ + return m_network.status != QStringLiteral("config_missing"); +} + +bool ActiveNetwork::isDevnet() const +{ + return m_network.id == QStringLiteral("devnet"); +} + +bool ActiveNetwork::needsIdentityProbe() const +{ + return m_network.status == QStringLiteral("loading") + || m_network.status == QStringLiteral("network_unknown"); +} + +void ActiveNetwork::sequencerChanged(bool available) +{ + if (isConfigured()) + clearIdentity(available ? QStringLiteral("loading") + : QStringLiteral("network_unknown")); +} + +void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable) +{ + if (!isConfigured()) + return; + if (!reachable) + clearIdentity(QStringLiteral("network_unknown")); + else if (!wasReachable) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::beginIdentityProbe() +{ + if (isConfigured()) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::finishIdentityProbe(const QString& identity) +{ + if (identity.isEmpty()) + clearIdentity(QStringLiteral("network_unknown")); + else if (identity != m_expectedIdentity) + clearIdentity(QStringLiteral("network_mismatch")); + else { + m_network.status = QStringLiteral("ready"); + m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:") + : QStringLiteral("block10:")) + + identity; + } +} + +bool ActiveNetwork::isValidIdentity(const QString& value) +{ + return isLowerHex(value, 64); +} + +void ActiveNetwork::clearIdentity(const QString& status) +{ + m_network.status = status; + m_network.fingerprint.clear(); +} diff --git a/apps/amm/src/ActiveNetwork.h b/apps/amm/src/ActiveNetwork.h index 771442b9..5802320a 100644 --- a/apps/amm/src/ActiveNetwork.h +++ b/apps/amm/src/ActiveNetwork.h @@ -15,3 +15,27 @@ struct ActiveNetworkSnapshot { QString ammProgramId; QStringList tokenIds; }; + +class ActiveNetwork final { +public: + bool load(); + + const QString& status() const { return m_network.status; } + bool isConfigured() const; + bool isDevnet() const; + bool needsIdentityProbe() const; + ActiveNetworkSnapshot snapshot() const { return m_network; } + + void sequencerChanged(bool available); + void reachabilityChanged(bool reachable, bool wasReachable); + void beginIdentityProbe(); + void finishIdentityProbe(const QString& identity); + + static bool isValidIdentity(const QString& value); + +private: + void clearIdentity(const QString& status); + + ActiveNetworkSnapshot m_network; + QString m_expectedIdentity; +}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 60a3836d..0c8f6f45 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,5 +1,7 @@ #include "AmmUiBackend.h" +#include + #include #include @@ -14,6 +16,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -22,7 +27,9 @@ #include "LogosWalletProvider.h" #include "NewPositionRuntime.h" #include "SwapRuntime.h" +#include "WalletAccountId.h" #include "WalletController.h" +#include "WalletIdlDecoder.h" #include "logos_api.h" #include "logos_sdk.h" @@ -43,23 +50,148 @@ namespace { const char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; } +namespace { +constexpr int CHECKPOINT_BLOCK_ID = 10; +constexpr int BLOCK_HASH_OFFSET = 40; +constexpr int BLOCK_HASH_SIZE = 32; +const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0')); + +QByteArray resource(const QString& path) +{ + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); +} + +QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) +{ + return QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("method"), method }, + { QStringLiteral("params"), params }, + }).toJson(QJsonDocument::Compact); +} + +QString blockHashFromResponse(const QByteArray& payload) +{ + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(payload, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QByteArray block = QByteArray::fromBase64( + document.object().value(QStringLiteral("result")).toString().toLatin1()); + if (block.size() < BLOCK_HASH_OFFSET + BLOCK_HASH_SIZE) + return {}; + return QString::fromLatin1(block.mid(BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE).toHex()); +} + +QString channelIdFromResponse(const QByteArray& payload) +{ + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(payload, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QString channel = document.object().value(QStringLiteral("result")).toString(); + return ActiveNetwork::isValidIdentity(channel) ? channel : QString(); +} + +QString decimalAdd(const QString& left, const QString& right) +{ + if (left.isEmpty() || right.isEmpty()) + return {}; + if (!std::all_of(left.cbegin(), left.cend(), [](QChar value) { return value.isDigit(); }) + || !std::all_of(right.cbegin(), right.cend(), [](QChar value) { return value.isDigit(); })) { + return {}; + } + QString result; + result.reserve(std::max(left.size(), right.size()) + 1); + qsizetype leftIndex = left.size(); + qsizetype rightIndex = right.size(); + int carry = 0; + while (leftIndex > 0 || rightIndex > 0 || carry > 0) { + const int leftDigit = leftIndex > 0 + ? left.at(--leftIndex).digitValue() : 0; + const int rightDigit = rightIndex > 0 + ? right.at(--rightIndex).digitValue() : 0; + const int sum = leftDigit + rightDigit + carry; + result.prepend(QChar(QLatin1Char('0').unicode() + sum % 10)); + carry = sum / 10; + } + while (result.size() > 1 && result.startsWith(QLatin1Char('0'))) + result.remove(0, 1); + return result; +} + +QJsonObject enumFields(const QJsonValue& value, const QString& variant) +{ + return value.toObject().value(variant).toObject(); +} + +WalletAccountRead accountRead(const WalletAccount& account) +{ + WalletAccountRead read; + read.accountId = account.address; + read.status = account.readStatus; + read.programOwner = account.programOwner; + read.dataHex = account.dataHex; + return read; +} +} + AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), m_logos(std::make_unique(m_logosAPI)), - m_wallet(std::make_unique(m_logosAPI)), + m_ownedWallet(std::make_unique(m_logosAPI)), + m_wallet(m_ownedWallet.get()), + m_definitionCache(*m_wallet), m_walletController(std::make_unique( *m_wallet, QStringLiteral("AmmUI"))), m_ammClient(std::make_unique()), - m_newPosition(std::make_unique(m_wallet.get(), m_ammClient.get())), - m_swap(std::make_unique(m_wallet.get(), m_ammClient.get())) + m_newPosition(std::make_unique(m_wallet, m_ammClient.get())), + m_swap(std::make_unique(m_wallet, m_ammClient.get())), + m_networkManager(new QNetworkAccessManager(this)), + m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), + m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) { - setWalletStateReady(false); - setNewPositionContext(m_newPosition->context( - QVariantMap(), networkSnapshot(), false, false)); + initialize(); +} +AmmUiBackend::AmmUiBackend(WalletProvider& wallet, QObject* parent) + : AmmUiBackendSimpleSource(parent), + m_logosAPI(nullptr), + m_wallet(&wallet), + m_definitionCache(*m_wallet), + m_walletController(std::make_unique( + *m_wallet, QStringLiteral("AmmUI"))), + m_ammClient(std::make_unique()), + m_newPosition(std::make_unique(m_wallet, m_ammClient.get())), + m_swap(std::make_unique(m_wallet, m_ammClient.get())), + m_networkManager(new QNetworkAccessManager(this)), + m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), + m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) +{ + initialize(); +} + +void AmmUiBackend::initialize() +{ + setWalletStateReady(false); + if (m_newPosition) { + setNewPositionContext(m_newPosition->context( + QVariantMap(), networkSnapshot(), false, false)); + } + setAssets({}); + setAssetStatus(QStringLiteral("idle")); + setAssetError({}); + m_network.load(); + m_idlRegistry.registerProgram( + m_network.snapshot().ammProgramId, QStringLiteral("AMM"), m_ammIdl); + publishNetworkState(); connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); + connect(m_walletController.get(), &WalletController::snapshotChanged, + this, &AmmUiBackend::refreshPortfolio); syncWalletState(); m_walletController->start(); QTimer::singleShot(0, this, [this]() { @@ -107,8 +239,10 @@ void AmmUiBackend::disconnectWallet() { m_walletController->disconnect(); setWalletStateReady(true); - m_newPosition->clearWalletAccounts(); - refreshNewPositionContext(QVariantMap()); + if (m_newPosition) { + m_newPosition->clearWalletAccounts(); + refreshNewPositionContext(QVariantMap()); + } } QString AmmUiBackend::createAccountPublic() @@ -138,6 +272,8 @@ QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic) void AmmUiBackend::refreshNewPositionContext(QVariantMap request) { + if (!m_newPosition) + return; const bool refreshWalletAccounts = request.take(QStringLiteral("refreshWalletAccounts")).toBool(); if (request.contains(QStringLiteral("recentTokenIds")) @@ -153,21 +289,45 @@ void AmmUiBackend::refreshNewPositionContext(QVariantMap request) QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request) { + if (!m_newPosition) + return {}; return m_newPosition->quote(request, networkSnapshot(), isWalletOpen()); } QVariantMap AmmUiBackend::submitNewPosition(QVariantMap request, QString quoteHash) { + if (!m_newPosition) + return {}; return m_newPosition->submit( request, quoteHash, networkSnapshot(), isWalletOpen()); } +bool AmmUiBackend::setAccountAlias(QString accountId, QString alias) +{ + return m_walletController->setAccountAlias(accountId, alias); +} + +bool AmmUiBackend::setPrimaryAccount(QString accountId) +{ + return m_walletController->setPrimaryAccount(accountId); +} + void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); const bool walletWasOpen = isWalletOpen(); - + if (state.syncStatus == QStringLiteral("opening") + || state.syncStatus == QStringLiteral("syncing")) { + m_definitionCache.cancelPending(); + } + const QString previousAddress = sequencerAddr(); + const bool wasReachable = sequencerReachable(); setIsWalletOpen(state.isWalletOpen); + setWalletStateReady(state.syncStatus != QStringLiteral("opening") + && state.syncStatus != QStringLiteral("syncing")); + setWalletSyncStatus(state.syncStatus); + setWalletSyncError(state.syncError); + setWalletCanSubmit(state.canSubmit()); setWalletExists(state.walletExists); setConfigPath(state.configPath); setStoragePath(state.storagePath); @@ -176,15 +336,27 @@ void AmmUiBackend::syncWalletState() setCurrentBlockHeight(state.currentBlockHeight); setSequencerAddr(state.sequencerAddress); setSequencerReachable(state.sequencerReachable); - - if (walletWasOpen && !state.isWalletOpen) + if (walletWasOpen && !state.isWalletOpen && m_newPosition) m_newPosition->clearWalletAccounts(); publishNetworkContext(); + setPrimaryAccountAddress(state.primaryAccountAddress); + setPrimaryAccountName(state.primaryAccountName); + + const bool addressChanged = previousAddress != state.sequencerAddress; + if (addressChanged) + m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); + if (addressChanged || wasReachable != state.sequencerReachable) + m_network.reachabilityChanged(state.sequencerReachable, wasReachable); + publishNetworkState(); + if (state.sequencerReachable && m_network.needsIdentityProbe()) + probeNetworkIdentity(); } void AmmUiBackend::publishNetworkContext() { + if (!m_newPosition) + return; setNewPositionContext(m_newPosition->context( m_newPositionHints, networkSnapshot(), isWalletOpen(), false)); } @@ -263,6 +435,8 @@ QString AmmUiBackend::normalizeAccountId(const QString& id) return t.toLower(); } // Try base58 -> hex via the wallet module. + if (!m_logos) + return {}; const QString hex = m_logos->logos_execution_zone.account_id_from_base58(t); return hex.toLower(); // account_id_from_base58 returns "" on failure } @@ -363,3 +537,269 @@ QVariantList AmmUiBackend::tokenList() } return out; } + +void AmmUiBackend::publishNetworkState() +{ + const ActiveNetworkSnapshot network = m_network.snapshot(); + setActiveNetwork(network.id); + setNetworkStatus(network.status); + setNetworkFingerprint(network.fingerprint); +} + +void AmmUiBackend::probeNetworkIdentity() +{ + if (m_identityProbeInFlight || !m_network.isConfigured() || sequencerAddr().isEmpty()) + return; + m_identityProbeInFlight = true; + m_network.beginIdentityProbe(); + publishNetworkState(); + const QString address = sequencerAddr(); + const bool devnet = m_network.isDevnet(); + const QString method = devnet ? QStringLiteral("getChannelId") + : QStringLiteral("getBlock"); + const QJsonArray params = devnet ? QJsonArray() + : QJsonArray { CHECKPOINT_BLOCK_ID }; + QNetworkRequest request{QUrl(address)}; + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/json")); + request.setTransferTimeout(4000); + QNetworkReply* reply = m_networkManager->post(request, jsonRpcBody(method, params)); + connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { + m_identityProbeInFlight = false; + if (address != sequencerAddr()) { + reply->deleteLater(); + probeNetworkIdentity(); + return; + } + const QByteArray payload = reply->readAll(); + const QString identity = devnet ? channelIdFromResponse(payload) + : blockHashFromResponse(payload); + m_network.finishIdentityProbe(identity); + reply->deleteLater(); + publishNetworkState(); + refreshPortfolio(); + }); +} + +void AmmUiBackend::refreshPortfolio() +{ + const quint64 generation = ++m_portfolioGeneration; + if (!m_walletController->state().isWalletOpen) { + m_definitionCache.cancelPending(); + setAssets({}); + setAssetStatus(QStringLiteral("idle")); + setAssetError({}); + return; + } + if (m_network.status() != QStringLiteral("ready")) { + invalidateDefinitionCache(); + setAssets({}); + setAssetStatus(QStringLiteral("blocked")); + setAssetError(m_network.status()); + return; + } + if (m_tokenIdl.isEmpty()) { + invalidateDefinitionCache(); + setAssetStatus(QStringLiteral("error")); + setAssetError(QStringLiteral("token_idl_missing")); + return; + } + const TokenDefinitionCacheKey key = definitionCacheKey(m_network.snapshot()); + setAssetStatus(QStringLiteral("loading")); + setAssetError({}); + if (m_appliedDefinitionKey && *m_appliedDefinitionKey == key + && m_definitionCache.contains(key)) { + applyWalletPortfolio(generation); + return; + } + m_definitionCache.read( + key, + [this, generation, key](QVector reads) { + applyDefinitions(generation, key, reads); + }); +} + +TokenDefinitionCacheKey AmmUiBackend::definitionCacheKey( + const ActiveNetworkSnapshot& network) const +{ + return { + network.id, + network.fingerprint, + sequencerAddr(), + network.tokenIds, + }; +} + +void AmmUiBackend::invalidateDefinitionCache() +{ + m_definitionCache.clear(); + m_appliedDefinitionKey.reset(); +} + +void AmmUiBackend::applyDefinitions( + quint64 generation, + const TokenDefinitionCacheKey& key, + const QVector& reads) +{ + if (generation != m_portfolioGeneration) + return; + const ActiveNetworkSnapshot network = m_network.snapshot(); + if (!(key == definitionCacheKey(network))) + return; + const WalletDecodeResult decoded = WalletIdlDecoder::decode(m_tokenIdl, reads); + if (!decoded.ok() || reads.size() != network.tokenIds.size() + || decoded.accounts.size() != reads.size()) { + invalidateDefinitionCache(); + setAssetStatus(QStringLiteral("error")); + setAssetError(decoded.error.isEmpty() + ? QStringLiteral("definition_decode_failed") + : decoded.error); + return; + } + + m_tokens.clear(); + m_tokenProgramId.clear(); + int unavailable = 0; + for (qsizetype index = 0; index < reads.size(); ++index) { + const WalletAccountRead& read = reads.at(index); + const WalletDecodedAccount& account = decoded.accounts.at(index); + TokenInfo token; + token.id = network.tokenIds.at(index); + token.name = QStringLiteral("Unknown token"); + token.status = QStringLiteral("unavailable"); + const QJsonObject fungible = enumFields(account.value, QStringLiteral("Fungible")); + if (read.ok() && account.status == QStringLiteral("decoded") + && account.typeName == QStringLiteral("TokenDefinition") + && !fungible.isEmpty() && read.programOwner != DEFAULT_PROGRAM_OWNER) { + token.name = fungible.value(QStringLiteral("name")).toString().trimmed(); + if (token.name.isEmpty()) + token.name = QStringLiteral("Unnamed token"); + token.programOwner = read.programOwner; + token.status = QStringLiteral("ready"); + if (m_tokenProgramId.isEmpty()) + m_tokenProgramId = read.programOwner; + else if (m_tokenProgramId != read.programOwner) { + invalidateDefinitionCache(); + setAssets({}); + setAssetStatus(QStringLiteral("error")); + setAssetError(QStringLiteral("token_program_mismatch")); + return; + } + } else { + ++unavailable; + } + m_tokens.append(std::move(token)); + } + if (m_tokenProgramId.isEmpty()) { + invalidateDefinitionCache(); + setAssets({}); + setAssetStatus(QStringLiteral("error")); + setAssetError(QStringLiteral("definitions_unavailable")); + return; + } + m_idlRegistry.registerProgram( + m_tokenProgramId, QStringLiteral("Token"), m_tokenIdl); + if (unavailable > 0) + invalidateDefinitionCache(); + else + m_appliedDefinitionKey = key; + setAssetError(unavailable > 0 + ? QStringLiteral("some_definitions_unavailable") + : QString()); + applyWalletPortfolio(generation); +} + +void AmmUiBackend::applyWalletPortfolio(quint64 generation) +{ + if (generation != m_portfolioGeneration) + return; + const WalletSnapshot snapshot = m_walletController->snapshot(); + QVector programReads; + for (const WalletAccount& account : snapshot.accounts) { + if (!account.isPublic || account.readStatus != QStringLiteral("ok")) + continue; + programReads.append(accountRead(account)); + } + + QHash balances; + QVector presentations; + const QVector programs = m_idlRegistry.decode(programReads); + for (const WalletDecodedProgram& program : programs) { + for (const WalletDecodedAccount& account : program.result.accounts) { + WalletAccountPresentation presentation; + presentation.address = account.id; + presentation.programName = program.programName; + presentation.accountType = account.typeName; + if (program.programId == m_tokenProgramId + && account.typeName == QStringLiteral("TokenHolding")) { + const QJsonObject fungible = enumFields( + account.value, QStringLiteral("Fungible")); + if (fungible.isEmpty()) + continue; + const QString encodedId = fungible + .value(QStringLiteral("definition_id")).toString(); + const QString definitionId = account.accountIds.value(encodedId); + const QString amount = fungible.value(QStringLiteral("balance")).toString(); + const QString current = balances.value(definitionId, QStringLiteral("0")); + const QString total = decimalAdd(current, amount); + if (!definitionId.isEmpty() && !total.isEmpty()) + balances.insert(definitionId, total); + presentation.kind = QStringLiteral("token_holding"); + presentation.definitionId = definitionId; + presentation.hiddenFromAccounts = true; + for (const TokenInfo& token : m_tokens) { + if (token.id == definitionId) { + presentation.semanticName = token.name + QStringLiteral(" holding"); + break; + } + } + } else if (program.programId == m_tokenProgramId + && account.typeName == QStringLiteral("TokenDefinition")) { + presentation.kind = QStringLiteral("token_definition"); + const QJsonObject fungible = enumFields( + account.value, QStringLiteral("Fungible")); + presentation.semanticName = fungible.value(QStringLiteral("name")).toString(); + } else if (program.programId == m_tokenProgramId + && account.typeName == QStringLiteral("TokenMetadata")) { + presentation.kind = QStringLiteral("token_metadata"); + } else { + presentation.kind = QStringLiteral("program"); + presentation.semanticName = account.typeName; + } + presentations.append(std::move(presentation)); + } + } + m_walletController->applyAccountPresentations(presentations); + + QVariantList assets; + QVariantList available; + int unavailableCount = 0; + for (const TokenInfo& token : m_tokens) { + const QString balance = balances.value(token.id, QStringLiteral("0")); + const bool positive = balance != QStringLiteral("0"); + QString displayDefinitionId = walletAccountIdToBase58(token.id); + if (displayDefinitionId.isEmpty()) + displayDefinitionId = token.id; + QVariantMap asset { + { QStringLiteral("name"), token.name }, + { QStringLiteral("symbol"), token.name }, + { QStringLiteral("balance"), balance }, + { QStringLiteral("definitionId"), token.id }, + { QStringLiteral("displayDefinitionId"), displayDefinitionId }, + { QStringLiteral("programOwner"), token.programOwner }, + { QStringLiteral("status"), token.status }, + { QStringLiteral("section"), positive ? QStringLiteral("assets") + : QStringLiteral("available") }, + }; + if (positive) + assets.append(std::move(asset)); + else + available.append(std::move(asset)); + if (token.status != QStringLiteral("ready")) + ++unavailableCount; + } + assets.append(available); + setAssets(assets); + setAssetStatus(unavailableCount > 0 ? QStringLiteral("partial") + : QStringLiteral("ready")); +} diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 005d4726..46324c95 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -2,18 +2,24 @@ #define AMM_UI_BACKEND_H #include +#include +#include +#include #include #include #include #include #include #include +#include #include "rep_AmmUiBackend_source.h" #include "ActiveNetwork.h" +#include "TokenDefinitionCache.h" #include "WalletAccountModel.h" +#include "WalletIdlDecoder.h" class LogosAPI; struct LogosModules; @@ -21,6 +27,7 @@ class AmmClient; class LogosWalletProvider; class NewPositionRuntime; class SwapRuntime; +class QNetworkAccessManager; class WalletController; // Source-side implementation of the AmmUiBackend .rep interface. @@ -32,6 +39,8 @@ class AmmUiBackend : public AmmUiBackendSimpleSource { public: explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); + // The injected provider must outlive the backend. + explicit AmmUiBackend(WalletProvider& wallet, QObject* parent = nullptr); ~AmmUiBackend() override; WalletAccountModel* accountModel() const; @@ -52,6 +61,8 @@ public slots: QString createNew(QString configPath, QString storagePath, QString password) override; bool openExisting() override; void disconnectWallet() override; + bool setAccountAlias(QString accountId, QString alias) override; + bool setPrimaryAccount(QString accountId) override; // AMM QVariantMap resolvePool(QString defAHex, QString defBHex) override; @@ -63,6 +74,13 @@ public slots: QVariantList tokenList() override; private: + struct TokenInfo { + QString id; + QString name; + QString programOwner; + QString status; + }; + void syncWalletState(); void publishNetworkContext(); @@ -85,6 +103,17 @@ public slots: // .bin, not a raw ELF) from $AMM_PROGRAM_BIN, or an empty QByteArray (with a // qWarning) if the env var is unset/unreadable/empty. QByteArray loadAmmElf(); + void publishNetworkState(); + void initialize(); + void probeNetworkIdentity(); + void refreshPortfolio(); + TokenDefinitionCacheKey definitionCacheKey( + const ActiveNetworkSnapshot& network) const; + void invalidateDefinitionCache(); + void applyDefinitions(quint64 generation, + const TokenDefinitionCacheKey& key, + const QVector& reads); + void applyWalletPortfolio(quint64 generation); LogosAPI* m_logosAPI; // Direct module handle for the AMM/swap path (resolvePool/swapExactInput/ @@ -93,11 +122,14 @@ public slots: // calls the AMM path needs, so keep a thin LogosModules over the same // LogosAPI as the wallet provider. std::unique_ptr m_logos; - std::unique_ptr m_wallet; + std::unique_ptr m_ownedWallet; + WalletProvider* m_wallet; + TokenDefinitionCache m_definitionCache; std::unique_ptr m_walletController; std::unique_ptr m_ammClient; std::unique_ptr m_newPosition; std::unique_ptr m_swap; + std::unique_ptr m_swap; QVariantMap m_newPositionHints; @@ -108,6 +140,16 @@ public slots: bool m_networkResolved = false; QString m_ammProgramIdCache; QStringList m_tokenIdsCache; + QNetworkAccessManager* m_networkManager; + ActiveNetwork m_network; + QByteArray m_tokenIdl; + QByteArray m_ammIdl; + WalletIdlRegistry m_idlRegistry; + QVector m_tokens; + QString m_tokenProgramId; + std::optional m_appliedDefinitionKey; + bool m_identityProbeInFlight = false; + quint64 m_portfolioGeneration = 0; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 447ca286..7a22166a 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -8,6 +8,9 @@ class AmmUiBackend // False while startup or reconnect is still resolving wallet state. This // stays distinct from isWalletOpen because a disconnected wallet is ready. PROP(bool walletStateReady READONLY) + PROP(QString walletSyncStatus READONLY) + PROP(QString walletSyncError READONLY) + PROP(bool walletCanSubmit READONLY) PROP(bool walletExists READONLY) PROP(QString configPath READONLY) PROP(QString storagePath READONLY) @@ -18,6 +21,15 @@ class AmmUiBackend // Whether the configured sequencer answered the last reachability probe. // Defaults true so the UI doesn't flash a warning before the first check. PROP(bool sequencerReachable READONLY) + PROP(QString primaryAccountAddress READONLY) + PROP(QString primaryAccountName READONLY) + + PROP(QString activeNetwork READONLY) + PROP(QString networkStatus READONLY) + PROP(QString networkFingerprint READONLY) + PROP(QVariantList assets READONLY) + PROP(QString assetStatus READONLY) + PROP(QString assetError READONLY) // Account management SLOT(QString createAccountPublic()) @@ -25,6 +37,8 @@ class AmmUiBackend SLOT(void refreshAccounts()) SLOT(void refreshBalances()) SLOT(QString getBalance(QString accountIdHex, bool isPublic)) + SLOT(bool setAccountAlias(QString accountId, QString alias)) + SLOT(bool setPrimaryAccount(QString accountId)) // New Position backend surface. QML calls these through logos.watch(...). // The QVariant payloads are stable maps/lists so the UI never assembles AMM diff --git a/apps/amm/src/TokenDefinitionCache.cpp b/apps/amm/src/TokenDefinitionCache.cpp new file mode 100644 index 00000000..c1b424f1 --- /dev/null +++ b/apps/amm/src/TokenDefinitionCache.cpp @@ -0,0 +1,104 @@ +#include "TokenDefinitionCache.h" + +#include + +bool TokenDefinitionCacheKey::isReusable() const +{ + return !networkId.isEmpty() + && !networkFingerprint.isEmpty() + && !sequencerAddress.isEmpty() + && !tokenIds.isEmpty(); +} + +bool TokenDefinitionCacheKey::operator==(const TokenDefinitionCacheKey& other) const +{ + return networkId == other.networkId + && networkFingerprint == other.networkFingerprint + && sequencerAddress == other.sequencerAddress + && tokenIds == other.tokenIds; +} + +TokenDefinitionCache::TokenDefinitionCache(WalletProvider& provider) + : m_provider(provider), + m_state(std::make_shared()) +{ +} + +TokenDefinitionCache::~TokenDefinitionCache() +{ + clear(); +} + +void TokenDefinitionCache::read(const TokenDefinitionCacheKey& key, Callback callback) +{ + if (contains(key)) { + callback(m_state->cachedReads); + return; + } + if (m_state->inFlight && m_state->inFlight->key == key) { + m_state->inFlight->callbacks.append(std::move(callback)); + return; + } + cancelPending(); + + const auto request = std::make_shared(); + request->key = key; + request->callbacks.append(std::move(callback)); + m_state->inFlight = request; + const std::weak_ptr state = m_state; + m_provider.readPublicAccountsAsync( + key.tokenIds, + [state, request](QVector reads) mutable { + const std::shared_ptr lockedState = state.lock(); + if (!lockedState || request->cancelled) + return; + if (lockedState->inFlight == request) { + lockedState->inFlight.reset(); + if (TokenDefinitionCache::isComplete(request->key, reads)) { + lockedState->cachedKey = request->key; + lockedState->cachedReads = reads; + } + } + + QVector callbacks = std::move(request->callbacks); + for (Callback& callback : callbacks) + callback(reads); + }); +} + +bool TokenDefinitionCache::contains(const TokenDefinitionCacheKey& key) const +{ + return m_state->cachedKey && *m_state->cachedKey == key; +} + +void TokenDefinitionCache::cancelPending() +{ + if (m_state->inFlight) { + m_state->inFlight->cancelled = true; + m_state->inFlight->callbacks.clear(); + } + m_state->inFlight.reset(); +} + +void TokenDefinitionCache::clear() +{ + m_state->cachedKey.reset(); + m_state->cachedReads.clear(); + cancelPending(); +} + +bool TokenDefinitionCache::isComplete( + const TokenDefinitionCacheKey& key, + const QVector& reads) +{ + if (!key.isReusable() || reads.size() != key.tokenIds.size()) + return false; + for (qsizetype index = 0; index < reads.size(); ++index) { + const WalletAccountRead& read = reads.at(index); + if (!read.ok() || read.accountId != key.tokenIds.at(index) + || read.programOwner.isEmpty() || read.dataHex.isEmpty()) { + return false; + } + } + return true; +} diff --git a/apps/amm/src/TokenDefinitionCache.h b/apps/amm/src/TokenDefinitionCache.h new file mode 100644 index 00000000..3cdf31b5 --- /dev/null +++ b/apps/amm/src/TokenDefinitionCache.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "WalletProvider.h" + +struct TokenDefinitionCacheKey { + QString networkId; + QString networkFingerprint; + QString sequencerAddress; + QStringList tokenIds; + + bool isReusable() const; + bool operator==(const TokenDefinitionCacheKey& other) const; +}; + +class TokenDefinitionCache final { +public: + using Callback = std::function)>; + + explicit TokenDefinitionCache(WalletProvider& provider); + ~TokenDefinitionCache(); + + void read(const TokenDefinitionCacheKey& key, Callback callback); + bool contains(const TokenDefinitionCacheKey& key) const; + void cancelPending(); + void clear(); + +private: + struct InFlight { + TokenDefinitionCacheKey key; + QVector callbacks; + bool cancelled = false; + }; + + struct State { + std::optional cachedKey; + QVector cachedReads; + std::shared_ptr inFlight; + }; + + static bool isComplete(const TokenDefinitionCacheKey& key, + const QVector& reads); + + WalletProvider& m_provider; + std::shared_ptr m_state; +}; diff --git a/apps/amm/src/WalletIdlDecoder.cpp b/apps/amm/src/WalletIdlDecoder.cpp new file mode 100644 index 00000000..986046de --- /dev/null +++ b/apps/amm/src/WalletIdlDecoder.cpp @@ -0,0 +1,100 @@ +#include "WalletIdlDecoder.h" + +#include + +#include +#include +#include +#include + +#include + +WalletDecodeResult WalletIdlDecoder::decode( + const QByteArray& idlJson, + const QVector& accounts) +{ + WalletDecodeResult result; + QJsonParseError idlError; + const QJsonDocument idl = QJsonDocument::fromJson(idlJson, &idlError); + if (idlError.error != QJsonParseError::NoError || !idl.isObject()) { + result.status = QStringLiteral("error"); + result.error = QStringLiteral("invalid_idl"); + return result; + } + + QJsonArray inputs; + for (const WalletAccountRead& account : accounts) { + inputs.append(QJsonObject { + { QStringLiteral("id"), account.accountId }, + { QStringLiteral("dataHex"), account.dataHex }, + }); + } + const QByteArray request = QJsonDocument(QJsonObject { + { QStringLiteral("idl"), idl.object() }, + { QStringLiteral("accounts"), inputs }, + }).toJson(QJsonDocument::Compact); + + char* responsePointer = wallet_idl_decode_accounts(request.constData()); + if (!responsePointer) { + result.status = QStringLiteral("error"); + result.error = QStringLiteral("decoder_unavailable"); + return result; + } + const QByteArray response(responsePointer); + wallet_idl_decoder_free(responsePointer); + + QJsonParseError responseError; + const QJsonDocument document = QJsonDocument::fromJson(response, &responseError); + if (responseError.error != QJsonParseError::NoError || !document.isObject()) { + result.status = QStringLiteral("error"); + result.error = QStringLiteral("invalid_decoder_response"); + return result; + } + + const QJsonObject root = document.object(); + result.status = root.value(QStringLiteral("status")).toString(); + result.error = root.value(QStringLiteral("error")).toString(); + for (const QJsonValue& value : root.value(QStringLiteral("accounts")).toArray()) { + const QJsonObject decoded = value.toObject(); + WalletDecodedAccount account; + account.id = decoded.value(QStringLiteral("id")).toString(); + account.status = decoded.value(QStringLiteral("status")).toString(); + account.typeName = decoded.value(QStringLiteral("typeName")).toString(); + account.value = decoded.value(QStringLiteral("value")); + const QJsonObject ids = decoded.value(QStringLiteral("accountIds")).toObject(); + for (auto iterator = ids.begin(); iterator != ids.end(); ++iterator) + account.accountIds.insert(iterator.key(), iterator.value().toString()); + result.accounts.append(std::move(account)); + } + return result; +} + +void WalletIdlRegistry::registerProgram(const QString& programId, + const QString& programName, + const QByteArray& idlJson) +{ + if (!programId.isEmpty() && !programName.isEmpty() && !idlJson.isEmpty()) + m_programs.insert(programId, { programName, idlJson }); +} + +QVector WalletIdlRegistry::decode( + const QVector& accounts) const +{ + QHash> grouped; + for (const WalletAccountRead& account : accounts) { + if (m_programs.contains(account.programOwner)) + grouped[account.programOwner].append(account); + } + + QVector decoded; + decoded.reserve(grouped.size()); + for (auto iterator = grouped.cbegin(); iterator != grouped.cend(); ++iterator) { + const Program program = m_programs.value(iterator.key()); + decoded.append({ + iterator.key(), + program.name, + WalletIdlDecoder::decode(program.idl, iterator.value()), + }); + } + return decoded; +} diff --git a/apps/amm/src/WalletIdlDecoder.h b/apps/amm/src/WalletIdlDecoder.h new file mode 100644 index 00000000..08112266 --- /dev/null +++ b/apps/amm/src/WalletIdlDecoder.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "WalletProvider.h" + +struct WalletDecodedAccount { + QString id; + QString status; + QString typeName; + QJsonValue value; + QHash accountIds; +}; + +struct WalletDecodeResult { + QString status; + QString error; + QVector accounts; + + bool ok() const { return status == QStringLiteral("ok"); } +}; + +class WalletIdlDecoder final { +public: + static WalletDecodeResult decode(const QByteArray& idlJson, + const QVector& accounts); +}; + +struct WalletDecodedProgram { + QString programId; + QString programName; + WalletDecodeResult result; +}; + +class WalletIdlRegistry final { +public: + void registerProgram(const QString& programId, + const QString& programName, + const QByteArray& idlJson); + QVector decode( + const QVector& accounts) const; + +private: + struct Program { + QString name; + QByteArray idl; + }; + + QHash m_programs; +}; diff --git a/apps/amm/tests/cpp/ActiveNetworkTest.cpp b/apps/amm/tests/cpp/ActiveNetworkTest.cpp new file mode 100644 index 00000000..0f5250ca --- /dev/null +++ b/apps/amm/tests/cpp/ActiveNetworkTest.cpp @@ -0,0 +1,47 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include +#include + +class ActiveNetworkTest : public QObject { + Q_OBJECT + +private slots: + void validatesIdentityBeforeReadiness(); +}; + +void ActiveNetworkTest::validatesIdentityBeforeReadiness() +{ + const QString identity(64, QLatin1Char('a')); + const QString programId(64, QLatin1Char('b')); + const QString tokenId(64, QLatin1Char('c')); + QTemporaryFile config; + QVERIFY(config.open()); + config.write(QJsonDocument(QJsonObject { + { QStringLiteral("channelId"), identity }, + { QStringLiteral("ammProgramId"), programId }, + { QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } }, + }).toJson(QJsonDocument::Compact)); + config.flush(); + qputenv("AMM_UI_NETWORK", "devnet"); + qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit()); + + ActiveNetwork network; + QVERIFY(network.load()); + QCOMPARE(network.status(), QStringLiteral("network_unknown")); + network.sequencerChanged(true); + network.finishIdentityProbe(QString(64, QLatin1Char('d'))); + QCOMPARE(network.status(), QStringLiteral("network_mismatch")); + network.reachabilityChanged(false, true); + network.reachabilityChanged(true, false); + network.finishIdentityProbe(identity); + QCOMPARE(network.status(), QStringLiteral("ready")); + QCOMPARE(network.snapshot().fingerprint, QStringLiteral("channel:") + identity); + QCOMPARE(network.snapshot().tokenIds, QStringList { tokenId }); +} + +QTEST_GUILESS_MAIN(ActiveNetworkTest) +#include "ActiveNetworkTest.moc" diff --git a/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp b/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp new file mode 100644 index 00000000..28e1bcbb --- /dev/null +++ b/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp @@ -0,0 +1,242 @@ +#include "AmmUiBackend.h" +#include "FakeWalletProvider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +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; +}; + +class LocalRpcServer final { +public: + explicit LocalRpcServer(QString channelId) + : m_channelId(std::move(channelId)) + { + QObject::connect(&m_server, &QTcpServer::newConnection, [&]() { + while (m_server.hasPendingConnections()) { + QTcpSocket* socket = m_server.nextPendingConnection(); + QObject::connect(socket, &QTcpSocket::readyRead, socket, + [this, socket]() { process(socket); }); + if (socket->bytesAvailable() > 0) + process(socket); + } + }); + } + + bool listen() + { + return m_server.listen(QHostAddress::LocalHost); + } + + QString endpoint() const + { + return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()); + } + +private: + void process(QTcpSocket* socket) + { + QByteArray& request = m_requests[socket]; + request.append(socket->readAll()); + const qsizetype headerEnd = request.indexOf("\r\n\r\n"); + if (headerEnd < 0) + return; + + qsizetype contentLength = 0; + for (QByteArray line : request.first(headerEnd).split('\n')) { + line = line.trimmed(); + if (line.toLower().startsWith("content-length:")) { + contentLength = line.mid(sizeof("content-length:") - 1) + .trimmed().toLongLong(); + } + } + if (request.size() - headerEnd - 4 < contentLength) + return; + + m_requests.remove(socket); + const QByteArray payload = QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("result"), m_channelId }, + }).toJson(QJsonDocument::Compact); + QByteArray response = QByteArrayLiteral( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: "); + response += QByteArray::number(payload.size()); + response += QByteArrayLiteral("\r\nConnection: close\r\n\r\n"); + response += payload; + socket->write(response); + socket->disconnectFromHost(); + } + + QTcpServer m_server; + QHash m_requests; + QString m_channelId; +}; + +QByteArray devnetConfig(const QString& channelId, + const QString& ammProgramId, + const QString& definitionId) +{ + return QJsonDocument(QJsonObject { + { QStringLiteral("channelId"), channelId }, + { QStringLiteral("ammProgramId"), ammProgramId }, + { QStringLiteral("tokenDefinitionIds"), QJsonArray { definitionId } }, + }).toJson(QJsonDocument::Compact); +} + +class BackendFixture final { +public: + BackendFixture() + : channelId(64, QLatin1Char('a')), + ammProgramId(64, QLatin1Char('b')), + definitionId(64, QLatin1Char('c')), + tokenProgramId(64, QLatin1Char('d')), + server(channelId) + { + } + + bool initialize(bool deferDefinitionReads = false) + { + if (!server.listen() || !directory.isValid()) + return false; + const QString walletHome = directory.filePath(QStringLiteral("wallet")); + if (!QDir().mkpath(walletHome)) + return false; + const QString configPath = directory.filePath(QStringLiteral("devnet.json")); + QFile config(configPath); + if (!config.open(QIODevice::WriteOnly)) + return false; + const QByteArray configData = devnetConfig(channelId, ammProgramId, definitionId); + if (config.write(configData) != qint64(configData.size())) + return false; + config.close(); + + network = std::make_unique( + QByteArrayLiteral("AMM_UI_NETWORK"), QByteArrayLiteral("devnet")); + devnetFile = std::make_unique( + QByteArrayLiteral("AMM_UI_DEVNET_FILE"), configPath.toLocal8Bit()); + walletHomeEnvironment = std::make_unique( + QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit()); + settingsHome = std::make_unique( + QByteArrayLiteral("XDG_CONFIG_HOME"), + directory.filePath(QStringLiteral("settings")).toLocal8Bit()); + QSettings settings(QStringLiteral("Logos"), QStringLiteral("AmmUI")); + settings.setValue(QStringLiteral("disconnected"), false); + settings.sync(); + + provider.connectResult.adopted = true; + provider.connectResult.snapshot.sequencerAddress = server.endpoint(); + provider.snapshotResult = provider.connectResult.snapshot; + provider.readResult.status = QStringLiteral("ok"); + provider.readResult.programOwner = tokenProgramId; + provider.readResult.dataHex = QStringLiteral( + "0004000000544553540a0000000000000000000000000000000000"); + provider.deferPublicAccountReads = deferDefinitionReads; + backend = std::make_unique(provider); + return true; + } + + QString channelId; + QString ammProgramId; + QString definitionId; + QString tokenProgramId; + LocalRpcServer server; + QTemporaryDir directory; + std::unique_ptr network; + std::unique_ptr devnetFile; + std::unique_ptr walletHomeEnvironment; + std::unique_ptr settingsHome; + FakeWalletProvider provider; + std::unique_ptr backend; +}; +} + +class AmmUiBackendDefinitionCacheTest : public QObject { + Q_OBJECT + +private slots: + void reusesDefinitionsAfterRefreshAndReopen(); + void restartsDefinitionReadAfterRefreshAndReopen(); +}; + +void AmmUiBackendDefinitionCacheTest::reusesDefinitionsAfterRefreshAndReopen() +{ + BackendFixture fixture; + QVERIFY(fixture.initialize()); + + QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready")); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 1); + QCOMPARE(fixture.provider.lastPublicAccountIds, + QStringList { fixture.definitionId }); + + fixture.backend->refreshBalances(); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 1); + + fixture.backend->disconnectWallet(); + QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed")); + QVERIFY(fixture.backend->openExisting()); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 1); +} + +void AmmUiBackendDefinitionCacheTest::restartsDefinitionReadAfterRefreshAndReopen() +{ + BackendFixture fixture; + QVERIFY(fixture.initialize(true)); + + QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready")); + QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 1); + QCOMPARE(fixture.backend->assetStatus(), QStringLiteral("loading")); + + fixture.backend->refreshBalances(); + QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 2); + + fixture.backend->disconnectWallet(); + QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed")); + QVERIFY(fixture.backend->openExisting()); + QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 3); + + fixture.provider.completePendingPublicAccountReads(); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 3); +} + +QTEST_GUILESS_MAIN(AmmUiBackendDefinitionCacheTest) +#include "AmmUiBackendDefinitionCacheTest.moc" diff --git a/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp b/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp new file mode 100644 index 00000000..1eecc303 --- /dev/null +++ b/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp @@ -0,0 +1,185 @@ +#include "FakeWalletProvider.h" +#include "TokenDefinitionCache.h" + +#include + +#include + +namespace { +TokenDefinitionCacheKey cacheKey(const QString& fingerprint = QStringLiteral("channel:one")) +{ + return { + QStringLiteral("devnet"), + fingerprint, + QStringLiteral("http://127.0.0.1:8080"), + { + QString(64, QLatin1Char('a')), + QString(64, QLatin1Char('b')), + }, + }; +} + +void makeReadsReady(FakeWalletProvider& provider) +{ + provider.readResult.status = QStringLiteral("ok"); + provider.readResult.programOwner = QString(64, QLatin1Char('c')); + provider.readResult.dataHex = QStringLiteral("00"); +} +} + +class TokenDefinitionCacheTest : public QObject { + Q_OBJECT + +private slots: + void reusesCompleteReads(); + void retriesIncompleteReads(); + void separatesNetworkKeys(); + void coalescesInFlightReads(); + void restartsCancelledRead(); + void dropsPendingCallbackOnDestruction(); +}; + +void TokenDefinitionCacheTest::reusesCompleteReads() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + + QVector first; + cache.read(key, [&first](QVector reads) { + first = std::move(reads); + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + QCOMPARE(provider.lastPublicAccountIds, key.tokenIds); + QCOMPARE(first.size(), key.tokenIds.size()); + QVERIFY(cache.contains(key)); + + QVector second; + cache.read(key, [&second](QVector reads) { + second = std::move(reads); + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + QCOMPARE(second.size(), first.size()); + for (qsizetype index = 0; index < second.size(); ++index) { + QCOMPARE(second.at(index).accountId, first.at(index).accountId); + QCOMPARE(second.at(index).status, first.at(index).status); + } +} + +void TokenDefinitionCacheTest::retriesIncompleteReads() +{ + FakeWalletProvider provider; + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + + cache.read(key, [](QVector) {}); + cache.read(key, [](QVector) {}); + + QCOMPARE(provider.publicAccountReadCalls, 2); + QVERIFY(!cache.contains(key)); +} + +void TokenDefinitionCacheTest::separatesNetworkKeys() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey baseKey = cacheKey(); + TokenDefinitionCacheKey fingerprintKey = baseKey; + fingerprintKey.networkFingerprint = QStringLiteral("channel:two"); + TokenDefinitionCacheKey endpointKey = baseKey; + endpointKey.sequencerAddress = QStringLiteral("http://127.0.0.1:8081"); + TokenDefinitionCacheKey definitionsKey = baseKey; + definitionsKey.tokenIds = { + baseKey.tokenIds.at(1), + baseKey.tokenIds.at(0), + }; + TokenDefinitionCacheKey networkKey = baseKey; + networkKey.networkId = QStringLiteral("testnet"); + + cache.read(baseKey, [](QVector) {}); + cache.read(fingerprintKey, [](QVector) {}); + cache.read(endpointKey, [](QVector) {}); + cache.read(definitionsKey, [](QVector) {}); + cache.read(networkKey, [](QVector) {}); + + QCOMPARE(provider.publicAccountReadCalls, 5); + QVERIFY(!cache.contains(baseKey)); + QVERIFY(cache.contains(networkKey)); +} + +void TokenDefinitionCacheTest::coalescesInFlightReads() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + provider.deferPublicAccountReads = true; + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + bool firstCalled = false; + bool secondCalled = false; + + cache.read(key, [&firstCalled](QVector) { + firstCalled = true; + }); + cache.read(key, [&secondCalled](QVector) { + secondCalled = true; + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + provider.completePendingPublicAccountReads(); + + QVERIFY(firstCalled); + QVERIFY(secondCalled); + QVERIFY(cache.contains(key)); +} + +void TokenDefinitionCacheTest::restartsCancelledRead() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + provider.deferPublicAccountReads = true; + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + bool cancelledCallback = false; + bool retryCallback = false; + + cache.read(key, [&cancelledCallback](QVector) { + cancelledCallback = true; + }); + cache.cancelPending(); + cache.read(key, [&retryCallback](QVector) { + retryCallback = true; + }); + + QCOMPARE(provider.publicAccountReadCalls, 2); + provider.completePendingPublicAccountReads(); + + QVERIFY(!cancelledCallback); + QVERIFY(retryCallback); + QVERIFY(cache.contains(key)); +} + +void TokenDefinitionCacheTest::dropsPendingCallbackOnDestruction() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + provider.deferPublicAccountReads = true; + const TokenDefinitionCacheKey key = cacheKey(); + bool callbackCalled = false; + + { + TokenDefinitionCache cache(provider); + cache.read(key, [&callbackCalled](QVector) { + callbackCalled = true; + }); + } + provider.completePendingPublicAccountReads(); + + QVERIFY(!callbackCalled); +} + +QTEST_GUILESS_MAIN(TokenDefinitionCacheTest) +#include "TokenDefinitionCacheTest.moc" diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 896716b8..8c32c116 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -29,6 +29,8 @@ if(LOGOS_WALLET_BUILD_ACCESS) src/WalletProvider.cpp src/LogosWalletProvider.h src/LogosWalletProvider.cpp + src/WalletAccountId.h + src/WalletAccountId.cpp src/WalletAccountModel.h src/WalletAccountModel.cpp src/WalletController.h @@ -59,13 +61,13 @@ if(LOGOS_WALLET_BUILD_QML) set(wallet_qml_output_dir "${CMAKE_CURRENT_BINARY_DIR}/qml/Logos/Wallet") set(wallet_internal_qml qml/internal/WalletIconButton.qml - qml/internal/CopyButton.qml qml/internal/AccountDelegate.qml qml/internal/CreateAccountDialog.qml qml/internal/CreateWalletDialog.qml qml/internal/WalletMessageDialog.qml ) set(wallet_public_qml + qml/internal/CopyButton.qml qml/WalletControl.qml qml/TransactionConfirmationDialog.qml qml/SubmittedTransaction.qml @@ -140,6 +142,8 @@ if(BUILD_TESTING) tests/cpp/LogosWalletProviderTest.cpp src/WalletProvider.cpp src/LogosWalletProvider.cpp + src/WalletAccountId.cpp + src/WalletAccountId.h src/WalletAccountModel.cpp src/WalletAccountModel.h src/WalletController.cpp @@ -168,8 +172,16 @@ if(BUILD_TESTING) target_link_libraries(logos_wallet_qml_test PRIVATE Qt6::QuickTest) add_dependencies(logos_wallet_qml_test logos_wallet_qmlplugin) add_test(NAME logos_wallet_qml COMMAND logos_wallet_qml_test) + get_target_property(wallet_qml_library Qt6::Qml IMPORTED_LOCATION) + get_filename_component(wallet_qml_library_dir "${wallet_qml_library}" DIRECTORY) + get_filename_component(wallet_qml_prefix "${wallet_qml_library_dir}" DIRECTORY) + set(wallet_qml_test_import_paths + "${CMAKE_CURRENT_BINARY_DIR}/qml" + "${wallet_qml_prefix}/${QT6_INSTALL_QML}" + ) + string(JOIN ":" wallet_qml_test_import_path ${wallet_qml_test_import_paths}) set_tests_properties(logos_wallet_qml PROPERTIES ENVIRONMENT - "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml;QML_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml" + "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${wallet_qml_test_import_path};QML_IMPORT_PATH=${wallet_qml_test_import_path}" ) endif() endif() diff --git a/apps/shared/wallet/qml/TransactionConfirmationDialog.qml b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml index 141a8db8..76cc91a0 100644 --- a/apps/shared/wallet/qml/TransactionConfirmationDialog.qml +++ b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml @@ -9,12 +9,21 @@ Popup { property string cancelText: qsTr("Cancel") property string confirmText: qsTr("Confirm") property bool busy: false + property string busyText: qsTr("Submitting…") + property bool activityBusy: false + property string activityText: qsTr("Updating…") + property bool showInlineBusyIndicator: true property var snapshot: ({}) property Component summary: null property bool confirmationPending: false + property bool confirmEnabled: true + property bool roundedCancelButton: false + property bool closeWhenSettled: true + readonly property bool actionPending: root.busy || root.activityBusy signal canceled signal confirmed(var snapshot) + signal summaryEdited(var snapshot) modal: true dim: true @@ -40,7 +49,14 @@ Popup { root.snapshot = root.cloneSnapshot(nextSnapshot) root.confirmationPending = false root.open() - cancelButton.forceActiveFocus() + Qt.callLater(function() { + if (cancelButtonLoader.item) + cancelButtonLoader.item.forceActiveFocus() + }) + } + + function updateSnapshot(nextSnapshot) { + root.snapshot = root.cloneSnapshot(nextSnapshot) } function cancel() { @@ -52,7 +68,7 @@ Popup { } function confirm() { - if (root.busy) + if (root.actionPending || !root.confirmEnabled) return root.confirmationPending = true root.confirmed(root.snapshot) @@ -62,10 +78,21 @@ Popup { } } + Connections { + target: summaryLoader.item + ignoreUnknownSignals: true + + function onSnapshotEdited(snapshot) { + root.updateSnapshot(snapshot) + root.summaryEdited(root.snapshot) + } + } + onBusyChanged: { if (!root.busy && root.confirmationPending) { root.confirmationPending = false - root.close() + if (root.closeWhenSettled) + root.close() } } @@ -117,26 +144,37 @@ Popup { } } - BusyIndicator { + Item { + id: inlineBusyIndicator + + property bool active: root.showInlineBusyIndicator && root.actionPending Layout.alignment: Qt.AlignHCenter - visible: root.busy - running: root.busy - Accessible.name: qsTr("Submitting transaction") + Layout.preferredWidth: active ? busySpinner.implicitWidth : 0 + Layout.preferredHeight: active ? busySpinner.implicitHeight : 0 + implicitWidth: busySpinner.implicitWidth + implicitHeight: busySpinner.implicitHeight + visible: active + + BusyIndicator { + id: busySpinner + + anchors.centerIn: parent + running: inlineBusyIndicator.active + Accessible.name: root.busy ? root.busyText : root.activityText + } } RowLayout { Layout.fillWidth: true spacing: 10 - Button { - id: cancelButton - objectName: "transactionCancelButton" + Loader { + id: cancelButtonLoader + objectName: "transactionCancelButtonLoader" Layout.fillWidth: true - implicitHeight: 44 - text: root.cancelText - enabled: !root.busy - Accessible.name: text - onClicked: root.cancel() + Layout.preferredHeight: 44 + sourceComponent: root.roundedCancelButton + ? roundedCancelButtonComponent : defaultCancelButtonComponent } Button { @@ -144,8 +182,8 @@ Popup { objectName: "transactionConfirmButton" Layout.fillWidth: true implicitHeight: 44 - text: root.busy ? qsTr("Submitting...") : root.confirmText - enabled: !root.busy + text: root.busy ? root.busyText : root.confirmText + enabled: !root.actionPending && root.confirmEnabled Accessible.name: text onClicked: root.confirm() @@ -166,4 +204,48 @@ Popup { } } } + + Component { + id: defaultCancelButtonComponent + + Button { + objectName: "transactionCancelButton" + anchors.fill: parent + text: root.cancelText + enabled: !root.busy + Accessible.name: text + onClicked: root.cancel() + } + } + + Component { + id: roundedCancelButtonComponent + + Button { + id: cancelButton + + objectName: "transactionCancelButton" + anchors.fill: parent + text: root.cancelText + enabled: !root.busy + Accessible.name: text + onClicked: root.cancel() + + background: Rectangle { + color: cancelButton.pressed ? "#3f3f46" + : cancelButton.hovered ? "#27272a" : "#18181b" + border.color: "#52525b" + border.width: 1 + radius: 6 + } + + contentItem: Label { + text: cancelButton.text + color: "#f4f4f5" + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } } diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml index 28d32a13..1536781d 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -12,15 +12,33 @@ Item { property var watchCall: null property bool compact: false property real viewportWidth: width - property int selectedIndex: 0 + property int selectedIndex: -1 property bool busy: false + property bool openPending: false + property bool advancedExpanded: false + property bool availableExpanded: false + property bool primarySelectionQueued: false + property string postCreationWarning: "" + property bool createdWalletAwaitingAcknowledgement: false + property string reportedOpenErrorKey: "" readonly property bool connected: root.wallet !== null && root.wallet.isWalletOpen + readonly property string syncStatus: root.wallet + ? String(root.wallet.walletSyncStatus || "closed") + : "closed" readonly property bool compactLayout: root.compact || root.viewportWidth < 680 + readonly property bool walletOpening: root.wallet !== null + && (root.wallet.walletSyncStatus === "opening" + || root.wallet.walletSyncStatus === "syncing") readonly property string selectedAddress: root.accountAt(root.selectedIndex, "address") + readonly property string selectedDisplayAddress: root.accountAt(root.selectedIndex, "displayAddress") readonly property string selectedName: root.accountAt(root.selectedIndex, "name") readonly property string selectedBalance: root.accountAt(root.selectedIndex, "balance") readonly property bool selectedIsPublic: root.accountAt(root.selectedIndex, "isPublic") === true + readonly property var walletAssets: root.wallet && root.wallet.assets ? root.wallet.assets : [] + readonly property int availableAssetCount: root.assetCount("available") + readonly property string primaryName: root.wallet && root.wallet.primaryAccountName + ? root.wallet.primaryAccountName : root.selectedName implicitWidth: root.connected ? connectedButton.implicitWidth : connectButton.implicitWidth implicitHeight: 40 @@ -30,11 +48,19 @@ Item { model: root.accountModel delegate: QtObject { required property string address + required property string displayAddress required property string name required property string balance required property bool isPublic + required property bool isPrimary + required property bool canBePrimary + required property string kind } - onCountChanged: root.clampSelection() + onCountChanged: { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + onObjectAdded: root.schedulePrimarySelection() } function accountAt(index, field) { @@ -42,26 +68,67 @@ Item { return entry ? entry[field] : (field === "isPublic" ? false : "") } - function clampSelection() { + function assetCount(sectionName) { + let count = 0 + for (const asset of root.walletAssets) { + if (asset.section === sectionName) + ++count + } + return count + } + + function syncPrimarySelection() { if (accounts.count === 0) { - root.selectedIndex = 0 - } else { - root.selectedIndex = Math.max(0, Math.min(root.selectedIndex, accounts.count - 1)) + root.selectedIndex = -1 + return + } + const requested = root.wallet && root.wallet.primaryAccountAddress + ? root.wallet.primaryAccountAddress : "" + for (let index = 0; index < accounts.count; ++index) { + const account = accounts.objectAt(index) + if (!account) + continue + if ((requested.length > 0 && account.address === requested) || account.isPrimary) { + root.selectedIndex = index + return + } + } + for (let index = 0; index < accounts.count; ++index) { + const account = accounts.objectAt(index) + if (!account) + continue + if (account.kind === "user" && account.canBePrimary) { + root.selectedIndex = index + return + } } + root.selectedIndex = -1 + } + + function schedulePrimarySelection() { + if (root.primarySelectionQueued) + return + root.primarySelectionQueued = true + Qt.callLater(function() { + root.primarySelectionQueued = false + if (root.connected) + root.syncPrimarySelection() + else + root.selectedIndex = -1 + }) } function shortAddress(address) { return address && address.length > 13 - ? address.substring(0, 6) + "..." + address.substring(address.length - 4) + ? address.substring(0, 6) + "…" + address.substring(address.length - 4) : address || "" } function watchResult(result, success, failure) { - if (root.watchCall) { + if (root.watchCall) root.watchCall(result, success, failure) - } else { + else success(result) - } } function showError(message) { @@ -69,22 +136,109 @@ Item { messageDialog.open() } + function walletRefreshWarning(subject) { + if (!root.wallet || root.wallet.walletSyncStatus !== "error") + return "" + return qsTr("%1 was created, but could not be refreshed. Reconnect the wallet to refresh it.") + .arg(subject) + } + + function openFailureKey() { + if (!root.wallet || root.wallet.walletSyncStatus !== "error") + return "" + return root.wallet.walletSyncError || "unknown" + } + + function openFailureMessage() { + const error = root.wallet ? root.wallet.walletSyncError : "" + return error + ? qsTr("Wallet could not be opened: %1").arg(error) + : qsTr("Wallet could not be opened.") + } + + function reportUnhandledOpenFailure() { + if (!root.wallet || root.openPending || root.wallet.isWalletOpen + || root.wallet.walletSyncStatus !== "error") { + return + } + const key = root.openFailureKey() + if (key === root.reportedOpenErrorKey) + return + root.reportedOpenErrorKey = key + root.busy = false + root.showError(root.openFailureMessage()) + } + + function openAccepted(result) { + return result === true || result === "true" || result === 1 || result === "1" + } + + function finishOpen() { + root.openPending = false + root.busy = false + } + + function failOpen(message) { + if (!root.openPending) + return + root.reportedOpenErrorKey = root.openFailureKey() + root.finishOpen() + root.showError(message) + } + + function settleOpenFromWalletState() { + if (!root.openPending || !root.wallet) + return + const status = root.wallet.walletSyncStatus + if (status === undefined) { + root.finishOpen() + return + } + if (status === "ready" && root.wallet.isWalletOpen) { + root.finishOpen() + return + } + if (status === "error") { + root.failOpen(root.openFailureMessage()) + return + } + if (status === "closed" && root.wallet.walletExists === false) + root.failOpen(qsTr("Wallet could not be opened.")) + } + function openWallet() { - if (!root.wallet || root.busy) + if (!root.wallet || root.busy || root.walletOpening) return root.busy = true + root.openPending = true try { root.watchResult(root.wallet.openExisting(), function(ok) { - root.busy = false + if (!root.openAccepted(ok)) { + root.failOpen(qsTr("Wallet could not be opened.")) + return + } + Qt.callLater(root.settleOpenFromWalletState) + }, function(error) { + root.failOpen(qsTr("Wallet could not be opened: %1").arg(error)) + }) + } catch (error) { + root.failOpen(qsTr("Wallet could not be opened: %1").arg(error)) + } + } + + function makePrimary(address) { + if (!root.wallet || !address) + return + try { + root.watchResult(root.wallet.setPrimaryAccount(address), function(ok) { if (!ok) - root.showError(qsTr("Wallet could not be opened.")) + root.showError(qsTr("This account cannot be primary.")) + root.syncPrimarySelection() }, function(error) { - root.busy = false - root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + root.showError(qsTr("Primary account could not be changed: %1").arg(error)) }) } catch (error) { - root.busy = false - root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + root.showError(qsTr("Primary account could not be changed: %1").arg(error)) } } @@ -106,18 +260,57 @@ Item { Connections { target: root.accountModel ignoreUnknownSignals: true - function onModelReset() { root.clampSelection() } - function onRowsInserted() { root.clampSelection() } - function onRowsRemoved() { root.clampSelection() } + function onModelReset() { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + function onRowsInserted() { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + function onRowsRemoved() { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + function onDataChanged() { + root.syncPrimarySelection() + if (root.selectedIndex < 0) + root.schedulePrimarySelection() + } } + Connections { + target: root.wallet + ignoreUnknownSignals: true + function onPrimaryAccountAddressChanged() { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + function onWalletSyncStatusChanged() { + if (!root.wallet || root.wallet.walletSyncStatus !== "error") + root.reportedOpenErrorKey = "" + Qt.callLater(root.settleOpenFromWalletState) + Qt.callLater(root.reportUnhandledOpenFailure) + } + function onWalletSyncErrorChanged() { + Qt.callLater(root.settleOpenFromWalletState) + Qt.callLater(root.reportUnhandledOpenFailure) + } + function onIsWalletOpenChanged() { Qt.callLater(root.settleOpenFromWalletState) } + function onWalletExistsChanged() { Qt.callLater(root.settleOpenFromWalletState) } + } + + Component.onCompleted: Qt.callLater(root.reportUnhandledOpenFailure) + onConnectedChanged: { if (!root.connected) { - root.selectedIndex = 0 + root.selectedIndex = -1 walletMenu.close() + } else { + root.syncPrimarySelection() + root.schedulePrimarySelection() } } - onViewportWidthChanged: { if (walletMenu.opened) Qt.callLater(walletMenu.updateAnchor) @@ -129,24 +322,20 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: !root.connected - enabled: root.wallet !== null && !root.busy + enabled: root.wallet !== null && !root.busy && !root.walletOpening implicitHeight: 40 implicitWidth: root.compactLayout ? 40 : 108 - text: root.compactLayout ? "" : root.busy ? qsTr("Connecting...") : qsTr("Connect") - display: root.compactLayout ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + text: root.compactLayout ? "" : (root.busy || root.walletOpening + ? qsTr("Connecting…") : qsTr("Connect")) icon.source: Qt.resolvedUrl("icons/account.svg") - icon.color: "#ffffff" - icon.width: 18 - icon.height: 18 - Accessible.name: qsTr("Connect wallet") + Accessible.name: qsTr("Connect AMM Wallet") ToolTip.text: Accessible.name ToolTip.visible: hovered && root.compactLayout background: Rectangle { - color: connectButton.pressed ? "#d95c1e" : "#f26a21" - radius: 6 + color: connectButton.pressed ? "#d97706" : "#f59e0b" + radius: 8 } - contentItem: RowLayout { spacing: 6 Image { @@ -159,12 +348,11 @@ Item { Layout.fillWidth: true visible: !root.compactLayout text: connectButton.text - color: "#ffffff" + color: "#18181b" font.bold: true horizontalAlignment: Text.AlignHCenter } } - onClicked: { if (root.wallet && root.wallet.walletExists) root.openWallet() @@ -181,42 +369,43 @@ Item { visible: root.connected enabled: !root.busy implicitHeight: 40 - implicitWidth: root.compactLayout ? 44 : Math.max(140, accountButtonLabel.implicitWidth + 58) - Accessible.name: qsTr("Wallet account %1").arg(root.selectedAddress) + implicitWidth: root.compactLayout ? 44 : Math.max(176, accountButtonLabel.implicitWidth + 54) + Accessible.name: qsTr("AMM Wallet, primary account %1").arg(root.primaryName) background: Rectangle { color: connectedButton.pressed ? "#3f3f46" : "#27272a" border.width: walletMenu.opened || connectedButton.activeFocus ? 1 : 0 - border.color: "#f26a21" - radius: 6 + border.color: "#f59e0b" + radius: 8 } - contentItem: RowLayout { spacing: 8 - Rectangle { Layout.preferredWidth: 8 Layout.preferredHeight: 8 radius: 4 - color: "#22c55e" + color: !root.wallet || root.wallet.networkStatus === undefined + || root.wallet.networkStatus === "ready" + ? "#22c55e" + : root.wallet.networkStatus === "loading" ? "#f59e0b" : "#ef4444" } - Label { id: accountButtonLabel Layout.fillWidth: true visible: !root.compactLayout - text: root.shortAddress(root.selectedAddress) || qsTr("Connected") - color: "#f4f4f5" - horizontalAlignment: Text.AlignHCenter + text: root.primaryName.length > 0 + ? qsTr("AMM Wallet · %1").arg(root.primaryName) + : qsTr("AMM Wallet") + color: "#fafafa" + font.bold: true + elide: Text.ElideRight } - Label { visible: !root.compactLayout - text: walletMenu.opened ? "\u25b4" : "\u25be" + text: walletMenu.opened ? "▴" : "▾" color: "#a1a1aa" } } - onClicked: { if (walletMenu.opened || Date.now() - walletMenu.lastClosedMs < 200) walletMenu.close() @@ -228,6 +417,7 @@ Item { Popup { id: walletMenu objectName: "walletMenu" + palette.windowText: "#d4d4d8" property real lastClosedMs: 0 property point anchorPosition: Qt.point(0, 0) readonly property var viewport: Overlay.overlay @@ -243,45 +433,41 @@ Item { Math.min(connectedButton.width - width, viewport.width - width - 12 - anchorPosition.x)) : connectedButton.width - width - y: opensAbove - ? -height - 8 - : connectedButton.height + 8 - width: Math.min(360, Math.max(0, Math.min(root.viewportWidth, + y: opensAbove ? -height - 8 : connectedButton.height + 8 + width: Math.min(400, Math.max(0, Math.min(root.viewportWidth, viewport ? viewport.width : root.viewportWidth) - 24)) height: Math.min(implicitHeight, availableMenuHeight) margins: 12 padding: 12 + focus: true closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside function updateAnchor() { if (viewport) anchorPosition = connectedButton.mapToItem(viewport, 0, 0) } - onAboutToShow: updateAnchor() - onClosed: { walletMenu.lastClosedMs = Date.now() if (walletStack.depth > 1) walletStack.pop(null, StackView.Immediate) } - Connections { target: walletMenu.opened ? walletMenu.viewport : null - function onWidthChanged() { Qt.callLater(walletMenu.updateAnchor) } function onHeightChanged() { Qt.callLater(walletMenu.updateAnchor) } } - background: Rectangle { color: "#18181b" border.color: "#3f3f46" border.width: 1 - radius: 8 + radius: 10 } contentItem: StackView { id: walletStack + objectName: "walletStack" + clip: true width: walletMenu.availableWidth height: walletMenu.availableHeight implicitWidth: walletMenu.availableWidth @@ -291,86 +477,216 @@ Item { Component { id: walletOverview - - ColumnLayout { - spacing: 12 - - RowLayout { - Layout.fillWidth: true - - Item { Layout.fillWidth: true } - - WalletIconButton { - objectName: "walletAccountsButton" - iconSource: Qt.resolvedUrl("icons/account.svg") - accessibleName: qsTr("Accounts") - onClicked: walletStack.push(accountList) - } - - WalletIconButton { - objectName: "walletDisconnectButton" - iconSource: Qt.resolvedUrl("icons/power.svg") - accessibleName: qsTr("Disconnect") - onClicked: { - walletMenu.close() - if (root.wallet) - root.wallet.disconnectWallet() - } - } - } - - Rectangle { - Layout.fillWidth: true - implicitHeight: accountCard.implicitHeight + 24 - color: "#27272a" - radius: 6 - - ColumnLayout { - id: accountCard - anchors.fill: parent - anchors.margins: 12 - spacing: 8 - - RowLayout { + ScrollView { + implicitHeight: Math.min(overviewContent.implicitHeight, 520) + contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ColumnLayout { + id: overviewContent + objectName: "walletOverviewContent" + width: parent.width + spacing: 12 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { Layout.fillWidth: true - + spacing: 1 Label { - text: root.selectedName || qsTr("Account") - color: "#f4f4f5" + text: qsTr("AMM Wallet") + color: "#fafafa" font.bold: true + font.pixelSize: 16 } - Label { - text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") + text: root.wallet && root.wallet.activeNetwork + ? root.wallet.activeNetwork : qsTr("Network unavailable") color: "#a1a1aa" font.pixelSize: 11 } - - Item { Layout.fillWidth: true } - - Label { - text: root.selectedBalance || "-" - color: "#f4f4f5" - font.bold: true + } + WalletIconButton { + objectName: "walletAccountsButton" + iconSource: Qt.resolvedUrl("icons/account.svg") + accessibleName: qsTr("Accounts") + onClicked: walletStack.push(accountList, StackView.Immediate) + } + WalletIconButton { + objectName: "walletDisconnectButton" + iconSource: Qt.resolvedUrl("icons/power.svg") + accessibleName: qsTr("Disconnect") + onClicked: { + walletMenu.close() + if (root.wallet) + root.wallet.disconnectWallet() } } + } - RowLayout { - Layout.fillWidth: true - spacing: 4 - - Label { + Rectangle { + Layout.fillWidth: true + implicitHeight: identityCard.implicitHeight + 24 + color: "#27272a" + radius: 8 + ColumnLayout { + id: identityCard + anchors.fill: parent + anchors.margins: 12 + spacing: 7 + RowLayout { Layout.fillWidth: true - text: root.selectedAddress + Label { + Layout.fillWidth: true + text: root.primaryName || qsTr("No primary account") + color: "#fafafa" + font.bold: true + elide: Text.ElideRight + } + Label { + text: qsTr("Primary") + color: "#fbbf24" + font.pixelSize: 11 + font.bold: true + } + } + Label { + objectName: "walletPrimaryAccountType" + visible: root.selectedIndex >= 0 + text: root.selectedIsPublic ? qsTr("Public user account") + : qsTr("Private account") color: "#a1a1aa" - font.family: "monospace" font.pixelSize: 11 - elide: Text.ElideMiddle } + RowLayout { + Layout.fillWidth: true + spacing: 4 + Label { + Layout.fillWidth: true + text: root.selectedDisplayAddress + color: "#71717a" + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + CopyButton { + visible: root.selectedDisplayAddress.length > 0 + onCopyRequested: root.copyToClipboard(root.selectedDisplayAddress) + } + } + } + } - CopyButton { - visible: root.selectedAddress.length > 0 - onCopyRequested: root.copyToClipboard(root.selectedAddress) + Label { + text: qsTr("Assets") + color: "#fafafa" + font.bold: true + } + Label { + visible: root.wallet && root.wallet.assetStatus === "loading" + text: qsTr("Loading balances…") + color: "#a1a1aa" + } + Repeater { + model: root.walletAssets + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + visible: modelData.section === "assets" + implicitHeight: visible ? 62 : 0 + color: "#27272a" + radius: 8 + RowLayout { + anchors.fill: parent + anchors.margins: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: modelData.name + color: "#fafafa" + font.bold: true + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: root.shortAddress(modelData.displayDefinitionId + || modelData.definitionId) + color: "#71717a" + font.family: "monospace" + font.pixelSize: 10 + } + } + Label { + text: modelData.balance + color: "#fafafa" + font.family: "monospace" + font.bold: true + } + CopyButton { + onCopyRequested: root.copyToClipboard( + modelData.displayDefinitionId || modelData.definitionId) + } + } + } + } + Label { + visible: (!root.walletAssets || root.walletAssets.length === 0) + && (!root.wallet || root.wallet.assetStatus !== "loading") + text: root.wallet && root.wallet.assetError + ? qsTr("Assets unavailable: %1").arg(root.wallet.assetError) + : qsTr("No assets yet") + color: "#a1a1aa" + wrapMode: Text.Wrap + } + Button { + objectName: "walletAvailableAssetsButton" + Layout.fillWidth: true + visible: root.availableAssetCount > 0 + text: root.availableExpanded ? qsTr("Hide available tokens") + : qsTr("Available tokens") + flat: true + onClicked: root.availableExpanded = !root.availableExpanded + } + Repeater { + model: root.walletAssets + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + visible: root.availableExpanded && modelData.section === "available" + implicitHeight: visible ? 58 : 0 + color: "#202023" + radius: 8 + RowLayout { + anchors.fill: parent + anchors.margins: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: modelData.name + color: modelData.status === "ready" ? "#d4d4d8" : "#a1a1aa" + elide: Text.ElideRight + } + Label { + text: root.shortAddress(modelData.displayDefinitionId + || modelData.definitionId) + color: "#71717a" + font.family: "monospace" + font.pixelSize: 10 + } + } + Label { + text: modelData.status === "ready" ? "0" : qsTr("Unavailable") + color: "#71717a" + font.pixelSize: 11 + } + CopyButton { + onCopyRequested: root.copyToClipboard( + modelData.displayDefinitionId || modelData.definitionId) + } } } } @@ -380,77 +696,173 @@ Item { Component { id: accountList - ColumnLayout { - spacing: 12 - + spacing: 10 RowLayout { Layout.fillWidth: true - WalletIconButton { + objectName: "walletAccountsBackButton" iconSource: Qt.resolvedUrl("icons/back.svg") accessibleName: qsTr("Back") - onClicked: walletStack.pop() + onClicked: walletStack.pop(null, StackView.Immediate) } - Label { Layout.fillWidth: true text: qsTr("Accounts") - color: "#f4f4f5" + color: "#fafafa" font.bold: true } } - + Label { + Layout.fillWidth: true + visible: walletMenu.availableMenuHeight >= 280 + text: qsTr("Choose the account used as your wallet identity. Program records stay under Advanced.") + color: "#a1a1aa" + font.pixelSize: 11 + wrapMode: Text.Wrap + } ListView { id: accountListView objectName: "walletAccountList" Layout.fillWidth: true Layout.fillHeight: true - Layout.minimumHeight: 0 - Layout.preferredHeight: Math.min(contentHeight, 260) + Layout.minimumHeight: 48 + Layout.preferredHeight: Math.min(contentHeight, 300) clip: true - spacing: 6 + spacing: 0 model: root.accountModel ScrollIndicator.vertical: ScrollIndicator { } - - delegate: AccountDelegate { + delegate: Item { + id: accountWrapper + required property int index + required property string name + required property string alias + required property string address + required property string displayAddress + required property string balance + required property bool isPublic + required property string kind + required property string section + required property string programName + required property string accountType + required property string visibility + required property bool canBePrimary + required property bool isPrimary + + readonly property bool shown: section === "accounts" + || (root.advancedExpanded && section === "advanced") width: ListView.view.width - highlighted: index === root.selectedIndex - onClicked: { - root.selectedIndex = index - walletStack.pop() + height: shown ? accountDelegate.implicitHeight + 6 : 0 + visible: shown + + function clicked() { + if (canBePrimary && !isPrimary) + root.makePrimary(address) + } + + AccountDelegate { + id: accountDelegate + width: parent.width + index: accountWrapper.index + name: accountWrapper.name + alias: accountWrapper.alias + address: accountWrapper.address + displayAddress: accountWrapper.displayAddress + balance: accountWrapper.balance + isPublic: accountWrapper.isPublic + kind: accountWrapper.kind + section: accountWrapper.section + programName: accountWrapper.programName + accountType: accountWrapper.accountType + visibility: accountWrapper.visibility + canBePrimary: accountWrapper.canBePrimary + isPrimary: accountWrapper.isPrimary + onMakePrimaryRequested: function(address) { root.makePrimary(address) } + onRenameRequested: function(address, alias) { + renameDialog.accountAddress = address + renameField.text = alias + renameDialog.open() + } + onCopyRequested: function(text) { root.copyToClipboard(text) } } - onCopyRequested: function(text) { root.copyToClipboard(text) } } } - - Button { - objectName: "walletAddAccountButton" + RowLayout { Layout.fillWidth: true - text: qsTr("Add account") - enabled: !root.busy - onClicked: createAccountDialog.open() + spacing: 6 + Button { + objectName: "walletAdvancedAccountsButton" + Layout.fillWidth: true + text: root.advancedExpanded ? qsTr("Hide Advanced") : qsTr("Advanced") + flat: true + onClicked: root.advancedExpanded = !root.advancedExpanded + } + Button { + objectName: "walletAddAccountButton" + Layout.fillWidth: true + text: qsTr("Add account") + enabled: !root.busy + onClicked: createAccountDialog.open() + } } } } } + Dialog { + id: renameDialog + objectName: "walletRenameDialog" + property string accountAddress: "" + parent: Overlay.overlay + modal: true + anchors.centerIn: parent + width: Math.min(360, parent ? parent.width - 32 : 360) + title: qsTr("Rename account") + standardButtons: Dialog.Save | Dialog.Cancel + TextField { + id: renameField + objectName: "walletAliasField" + width: parent.width + maximumLength: 40 + placeholderText: qsTr("Account name") + Accessible.name: qsTr("Account name") + } + onAccepted: { + if (!root.wallet) + return + try { + root.watchResult(root.wallet.setAccountAlias(accountAddress, renameField.text), + function(ok) { + if (!ok) + root.showError(qsTr("Account name could not be saved.")) + }, function(error) { + root.showError(qsTr("Account name could not be saved: %1").arg(error)) + }) + } catch (error) { + root.showError(qsTr("Account name could not be saved: %1").arg(error)) + } + } + } + CreateWalletDialog { id: createWalletDialog objectName: "createWalletDialog" walletHome: root.wallet ? root.wallet.walletHome || "" : "" busy: root.busy - onCreateRequested: function(password) { if (!root.wallet || root.busy) return + root.postCreationWarning = "" + root.createdWalletAwaitingAcknowledgement = false root.busy = true try { root.watchResult(root.wallet.createNewDefault(password), function(mnemonic) { root.busy = false - if (mnemonic && mnemonic.length > 0) + if (mnemonic && mnemonic.length > 0) { createWalletDialog.mnemonic = mnemonic - else + root.createdWalletAwaitingAcknowledgement = true + root.postCreationWarning = root.walletRefreshWarning(qsTr("Wallet")) + } else createWalletDialog.errorText = qsTr("Wallet could not be created.") }, function(error) { root.busy = false @@ -461,30 +873,40 @@ Item { createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error) } } - onCopyRequested: function(text) { root.copyToClipboard(text) } + onClosed: { + const warning = root.postCreationWarning.length > 0 ? root.postCreationWarning + : root.createdWalletAwaitingAcknowledgement + ? root.walletRefreshWarning(qsTr("Wallet")) : "" + root.postCreationWarning = "" + root.createdWalletAwaitingAcknowledgement = false + if (warning.length > 0) + root.showError(warning) + } } CreateAccountDialog { id: createAccountDialog objectName: "createAccountDialog" busy: root.busy - onCreateRequested: function(isPublic) { if (!root.wallet || root.busy) return root.busy = true try { - const request = isPublic - ? root.wallet.createAccountPublic() - : root.wallet.createAccountPrivate() + const request = isPublic ? root.wallet.createAccountPublic() + : root.wallet.createAccountPrivate() root.watchResult(request, function(accountId) { root.busy = false if (accountId && accountId.length > 0) { createAccountDialog.close() - } else { + Qt.callLater(function() { + const warning = root.walletRefreshWarning(qsTr("Account")) + if (warning.length > 0) + root.showError(warning) + }) + } else root.showError(qsTr("Account could not be created.")) - } }, function(error) { root.busy = false root.showError(qsTr("Account could not be created: %1").arg(error)) diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml index b0ff529f..ea5da2ba 100644 --- a/apps/shared/wallet/qml/internal/AccountDelegate.qml +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -7,71 +7,138 @@ ItemDelegate { required property int index required property string name + required property string alias required property string address + required property string displayAddress required property string balance required property bool isPublic + required property string kind + required property string section + required property string programName + required property string accountType + required property string visibility + required property bool canBePrimary + required property bool isPrimary signal copyRequested(string text) + signal makePrimaryRequested(string address) + signal renameRequested(string address, string alias) leftPadding: 12 rightPadding: 8 topPadding: 10 bottomPadding: 10 + enabled: root.section !== "hidden" + Accessible.name: root.isPrimary + ? qsTr("%1, primary account").arg(root.name) + : root.name - Accessible.name: qsTr("%1, balance %2").arg(root.name).arg(root.balance || "0") + function kindLabel() { + if (root.kind === "user") + return qsTr("User") + if (root.kind === "private") + return qsTr("Account") + if (root.accountType.length > 0) + return root.accountType + return root.kind === "unknown" ? qsTr("Unknown") : qsTr("Program") + } background: Rectangle { - color: root.highlighted || root.hovered ? "#27272a" : "#18181b" - radius: 6 - border.width: root.activeFocus ? 1 : 0 - border.color: "#f26a21" + color: root.isPrimary || root.hovered ? "#27272a" : "#18181b" + radius: 8 + border.width: root.activeFocus || root.isPrimary ? 1 : 0 + border.color: root.isPrimary ? "#f59e0b" : "#52525b" } contentItem: ColumnLayout { - spacing: 6 + spacing: 7 RowLayout { Layout.fillWidth: true - spacing: 8 + spacing: 7 Label { + Layout.fillWidth: true text: root.name - color: "#f4f4f5" + color: "#fafafa" font.bold: true + elide: Text.ElideRight } Label { - text: root.isPublic ? qsTr("Public") : qsTr("Private") - color: "#a1a1aa" + visible: root.isPrimary + text: qsTr("Primary") + color: "#fbbf24" font.pixelSize: 11 + font.bold: true } - Item { Layout.fillWidth: true } + Label { + text: root.kindLabel() + color: "#a1a1aa" + font.pixelSize: 11 + } Label { - text: root.balance.length > 0 ? root.balance : "-" - color: "#f4f4f5" - font.bold: true + text: root.visibility === "private" ? qsTr("Private") : qsTr("Public") + color: root.visibility === "private" ? "#c4b5fd" : "#93c5fd" + font.pixelSize: 11 } } + Label { + visible: root.programName.length > 0 + Layout.fillWidth: true + text: qsTr("%1 program · wallet controlled").arg(root.programName) + color: "#a1a1aa" + font.pixelSize: 11 + elide: Text.ElideRight + } + RowLayout { Layout.fillWidth: true spacing: 4 Label { Layout.fillWidth: true - text: root.address - color: "#a1a1aa" + text: root.displayAddress + color: "#71717a" font.family: "monospace" font.pixelSize: 11 elide: Text.ElideMiddle } CopyButton { - visible: root.address.length > 0 - onCopyRequested: root.copyRequested(root.address) + visible: root.displayAddress.length > 0 + onCopyRequested: root.copyRequested(root.displayAddress) } } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Button { + objectName: "walletRenameButton" + text: qsTr("Rename") + flat: true + onClicked: root.renameRequested(root.address, root.alias) + } + + Item { Layout.fillWidth: true } + + Button { + objectName: "walletMakePrimaryButton" + visible: root.canBePrimary && !root.isPrimary + text: qsTr("Make primary") + flat: true + onClicked: root.makePrimaryRequested(root.address) + } + } + } + + onClicked: { + if (root.canBePrimary && !root.isPrimary) + root.makePrimaryRequested(root.address) } } diff --git a/apps/shared/wallet/qml/internal/CopyButton.qml b/apps/shared/wallet/qml/internal/CopyButton.qml index 1964da3c..b2f664fb 100644 --- a/apps/shared/wallet/qml/internal/CopyButton.qml +++ b/apps/shared/wallet/qml/internal/CopyButton.qml @@ -5,9 +5,11 @@ WalletIconButton { signal copyRequested + property string copyText: "" + property string copyLabel: qsTr("Copy") property bool copied: false - accessibleName: root.copied ? qsTr("Copied") : qsTr("Copy") + accessibleName: root.copied ? qsTr("Copied") : root.copyLabel iconSource: root.copied ? Qt.resolvedUrl("icons/checkmark.svg") : Qt.resolvedUrl("icons/copy.svg") @@ -18,7 +20,24 @@ WalletIconButton { onTriggered: root.copied = false } + TextEdit { + id: clipboardProxy + + visible: false + } + + function copyToClipboard() { + if (root.copyText.length === 0) + return + clipboardProxy.text = root.copyText + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + onClicked: { + root.copyToClipboard() root.copyRequested() root.copied = true resetTimer.restart() diff --git a/apps/shared/wallet/qml/internal/CreateAccountDialog.qml b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml index 51d5f31f..b4998da1 100644 --- a/apps/shared/wallet/qml/internal/CreateAccountDialog.qml +++ b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml @@ -16,9 +16,13 @@ Popup { x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 padding: 20 + focus: true closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside - onOpened: privateSwitch.checked = false + onOpened: { + privateSwitch.checked = false + Qt.callLater(function() { privateSwitch.forceActiveFocus() }) + } background: Rectangle { color: "#18181b" diff --git a/apps/shared/wallet/qml/internal/CreateWalletDialog.qml b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml index ac422c1f..e1ca2dee 100644 --- a/apps/shared/wallet/qml/internal/CreateWalletDialog.qml +++ b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml @@ -20,6 +20,7 @@ Popup { x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 padding: 20 + focus: true closePolicy: root.busy || root.mnemonic.length > 0 ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside diff --git a/apps/shared/wallet/qml/internal/WalletMessageDialog.qml b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml index 7f7a3467..47c39633 100644 --- a/apps/shared/wallet/qml/internal/WalletMessageDialog.qml +++ b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml @@ -15,8 +15,11 @@ Popup { x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 padding: 20 + focus: true closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + onOpened: Qt.callLater(function() { closeButton.forceActiveFocus() }) + background: Rectangle { color: "#18181b" border.color: "#3f3f46" @@ -44,6 +47,8 @@ Popup { } Button { + id: closeButton + Layout.alignment: Qt.AlignRight text: qsTr("Close") onClicked: root.close() diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp index 42fd4e66..fb06a83a 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.cpp +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -1,11 +1,14 @@ #include "LogosWalletProvider.h" #include +#include #include #include #include #include #include +#include +#include #include #include @@ -74,6 +77,103 @@ WalletCreation failedCreation(WalletFailure failure) creation.snapshot.failure = failure; return creation; } + +WalletAccountRead parsePublicAccount(const QString& accountId, const QString& payload) +{ + WalletAccountRead read; + read.accountId = accountId; + if (!isHex(accountId, 64)) + return read; + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return read; + + const QJsonObject account = document.object(); + const QString owner = account.value(QStringLiteral("program_owner")).toString(); + const QString balance = account.value(QStringLiteral("balance")).toString(); + const QString nonce = account.value(QStringLiteral("nonce")).toString(); + const QString data = account.value(QStringLiteral("data")).toString(); + if (!isHex(owner, 64) + || !isHex(balance, 32) + || !isHex(nonce, 32) + || data.size() % 2 != 0 + || !isHex(data, data.size())) { + return read; + } + + read.status = QStringLiteral("ok"); + read.programOwner = owner; + read.balanceHex = balance; + read.nonceHex = nonce; + read.dataHex = data; + return read; +} + +void applyPublicRead(WalletAccount& account, const WalletAccountRead& read) +{ + account.readStatus = read.status; + account.programOwner = read.programOwner; + account.dataHex = read.dataHex; +} + +bool encodeTransaction(const WalletTransaction& transaction, + QVariantList* signingRequirements, + QByteArray* 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( + static_cast(transaction.instruction.size() * sizeof(quint32))); + for (const quint32 word : transaction.instruction) { + instruction->append(static_cast(word & 0xff)); + instruction->append(static_cast((word >> 8) & 0xff)); + instruction->append(static_cast((word >> 16) & 0xff)); + instruction->append(static_cast((word >> 24) & 0xff)); + } + 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 { @@ -103,12 +203,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); @@ -131,9 +235,74 @@ WalletSession LogosWalletProvider::connect(const WalletPaths& paths) return session; } +void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback callback) +{ + ++m_sessionGeneration; + clearSnapshot(); + const quint64 generation = ++m_generation; + if (!m_impl->logos) { + QTimer::singleShot(0, [callback = std::move(callback)]() mutable { + callback(failedSession(WalletFailure::WalletUnavailable)); + }); + return; + } + + auto finishOpen = [this, generation, callback = std::move(callback)]( + bool adopted, WalletFailure failure) mutable { + if (generation != m_generation) + return; + if (failure != WalletFailure::None) { + callback(failedSession(failure)); + return; + } + m_connected = true; + loadSnapshotAsync(generation, + [this, generation, adopted, callback = std::move(callback)]( + WalletSnapshot snapshot) mutable { + if (generation != m_generation) + return; + WalletSession session; + session.adopted = adopted; + session.failure = snapshot.failure; + session.snapshot = std::move(snapshot); + callback(std::move(session)); + }); + }; + + auto openStored = [this, generation, paths, finishOpen]() mutable { + if (generation != m_generation) + return; + if (!QFileInfo::exists(paths.storage)) { + finishOpen(false, WalletFailure::WalletMissing); + return; + } + m_impl->logos->logos_execution_zone.openAsync( + paths.config, paths.storage, + [this, generation, finishOpen](int result) mutable { + if (generation != m_generation) + return; + finishOpen(false, result == WALLET_FFI_SUCCESS + ? WalletFailure::None : WalletFailure::OpenFailed); + }); + }; + + m_impl->logos->logos_execution_zone.get_sequencer_addrAsync( + [this, generation, finishOpen, openStored](QString address) mutable { + if (generation != m_generation) + return; + if (!address.isEmpty()) { + finishOpen(true, WalletFailure::None); + return; + } + openStored(); + }); +} + WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, const QString& password) { + ++m_generation; + ++m_sessionGeneration; clearSnapshot(); if (!m_impl->logos) return failedCreation(WalletFailure::WalletUnavailable); @@ -158,8 +327,6 @@ WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, return creation; } - creation.snapshot = snapshot(true); - creation.failure = creation.snapshot.failure; return creation; } @@ -181,6 +348,26 @@ WalletSnapshot LogosWalletProvider::snapshot(bool forceRefresh) return result; } +void LogosWalletProvider::snapshotAsync(bool forceRefresh, SnapshotCallback callback) +{ + if (m_snapshotReady && !forceRefresh) { + const WalletSnapshot snapshot = m_snapshot; + QTimer::singleShot(0, [callback = std::move(callback), snapshot]() mutable { + callback(snapshot); + }); + return; + } + if (!m_connected) { + WalletSnapshot snapshot; + snapshot.failure = WalletFailure::WalletUnavailable; + QTimer::singleShot(0, [callback = std::move(callback), snapshot]() mutable { + callback(snapshot); + }); + return; + } + loadSnapshotAsync(++m_generation, std::move(callback)); +} + void LogosWalletProvider::clearSnapshot() { m_snapshot = {}; @@ -209,45 +396,248 @@ WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic) if (isPublic) creation.publicAccount = readPublicAccount(creation.accountId); - - clearSnapshot(); - creation.snapshot = snapshot(true); + if (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( + m_snapshot.publicAccountReads.begin(), + m_snapshot.publicAccountReads.end(), + [&creation](const WalletAccountRead& existing) { + return existing.accountId == creation.accountId; + }); + if (read == m_snapshot.publicAccountReads.end()) + m_snapshot.publicAccountReads.append(creation.publicAccount); + else + *read = creation.publicAccount; + } else { + account.balance = m_impl->logos->logos_execution_zone.get_balance( + creation.accountId, isPublic); + } + auto existing = std::find_if( + m_snapshot.accounts.begin(), m_snapshot.accounts.end(), + [&creation](const WalletAccount& candidate) { + return candidate.address == creation.accountId; + }); + if (existing == m_snapshot.accounts.end()) + m_snapshot.accounts.append(account); + else + *existing = account; + creation.snapshot = m_snapshot; + } return creation; } +void LogosWalletProvider::createAccountAsync(bool isPublic, + AccountCreationCallback callback) +{ + QPointer 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 { - WalletAccountRead read; - read.accountId = accountId; if (!m_impl->logos || !isHex(accountId, 64)) - return read; - - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson( - m_impl->logos->logos_execution_zone.get_account_public(accountId).toUtf8(), - &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) - return read; + return WalletAccountRead { accountId }; + return parsePublicAccount( + accountId, + m_impl->logos->logos_execution_zone.get_account_public(accountId)); +} - const QJsonObject account = document.object(); - const QString owner = account.value(QStringLiteral("program_owner")).toString(); - const QString balance = account.value(QStringLiteral("balance")).toString(); - const QString nonce = account.value(QStringLiteral("nonce")).toString(); - const QString data = account.value(QStringLiteral("data")).toString(); - if (!isHex(owner, 64) - || !isHex(balance, 32) - || !isHex(nonce, 32) - || data.size() % 2 != 0 - || !isHex(data, data.size())) { - return read; +void LogosWalletProvider::readPublicAccountsAsync( + const QStringList& accountIds, + AccountReadsCallback callback) +{ + if (!m_impl->logos || accountIds.isEmpty()) { + QTimer::singleShot(0, + [callback = std::move(callback)]() mutable { callback({}); }); + return; } - read.status = QStringLiteral("ok"); - read.programOwner = owner; - read.balanceHex = balance; - read.nonceHex = nonce; - read.dataHex = data; - return read; + struct BatchState { + QVector reads; + qsizetype remaining = 0; + AccountReadsCallback callback; + }; + const quint64 generation = m_generation; + auto state = std::make_shared(); + state->reads.resize(accountIds.size()); + state->remaining = accountIds.size(); + state->callback = std::move(callback); + for (qsizetype index = 0; index < accountIds.size(); ++index) { + const QString accountId = accountIds.at(index); + if (!isHex(accountId, 64)) { + state->reads[index] = WalletAccountRead { accountId }; + if (--state->remaining == 0) + state->callback(std::move(state->reads)); + continue; + } + m_impl->logos->logos_execution_zone.get_account_publicAsync( + accountId, + [this, generation, state, index, accountId](QString payload) mutable { + if (generation != m_generation) + return; + state->reads[index] = parsePublicAccount(accountId, payload); + if (--state->remaining == 0) + state->callback(std::move(state->reads)); + }); + } } WalletSubmission LogosWalletProvider::submitPublicTransaction( @@ -258,73 +648,73 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction( submission.failure = WalletFailure::WalletUnavailable; return submission; } - if (!isHex(transaction.programId, 64) - || transaction.accountIds.size() != transaction.signingRequirements.size()) { + QVariantList signingRequirements; + QByteArray 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); - - // `send_generic_public_transaction`'s `instruction` param is a byte string - // (bstr). Passing a QVariantList makes the module's QtRO glue mangle it, - // so the guest reads a garbage Instruction variant. Send the little-endian - // bytes of the u32 words instead — same encoding the AMM swap path uses. - // See docs/amm-swap-qtro-serialization-bug.md. - QByteArray instructionBytes; - instructionBytes.reserve( - static_cast(transaction.instruction.size() * sizeof(quint32))); - for (const quint32 word : transaction.instruction) { - instructionBytes.append(static_cast(word & 0xff)); - instructionBytes.append(static_cast((word >> 8) & 0xff)); - instructionBytes.append(static_cast((word >> 16) & 0xff)); - instructionBytes.append(static_cast((word >> 24) & 0xff)); - } const QString response = m_impl->logos->logos_execution_zone.send_generic_public_transaction( transaction.accountIds, signingRequirements, - QVariant::fromValue(instructionBytes), + 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 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; + QByteArray 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(); @@ -335,9 +725,8 @@ bool LogosWalletProvider::sharedWalletIsOpen() const { if (!m_impl->logos) return false; - if (!m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty()) - return true; - return !m_impl->logos->logos_execution_zone.list_accounts().isEmpty(); + // A live wallet always has a configured, non-empty sequencer URL. + return !m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty(); } WalletSnapshot LogosWalletProvider::loadSnapshot() @@ -372,20 +761,170 @@ WalletSnapshot LogosWalletProvider::loadSnapshot() if (account.isPublic) { const WalletAccountRead read = readPublicAccount(address); result.publicAccountReads.append(read); + applyPublicRead(account, read); account.balance = read.ok() ? littleEndianU128ToDecimal(read.balanceHex) : m_impl->logos->logos_execution_zone.get_balance(address, true); } else { + account.readStatus = QStringLiteral("private"); account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false); } result.accounts.append(account); } - if (!save()) - result.failure = WalletFailure::SaveFailed; return result; } +void LogosWalletProvider::loadSnapshotAsync(quint64 generation, SnapshotCallback callback) +{ + if (!m_impl->logos || generation != m_generation) + return; + + m_impl->logos->logos_execution_zone.get_current_block_heightAsync( + [this, generation, callback = std::move(callback)](int currentHeight) mutable { + if (generation != m_generation) + return; + + auto afterSync = [this, generation, currentHeight, + callback = std::move(callback)](int syncResult) mutable { + if (generation != m_generation) + return; + if (syncResult != WALLET_FFI_SUCCESS) { + WalletSnapshot failed; + failed.failure = WalletFailure::ReadFailed; + callback(std::move(failed)); + return; + } + + m_impl->logos->logos_execution_zone.get_last_synced_blockAsync( + [this, generation, currentHeight, + callback = std::move(callback)](int lastSynced) mutable { + if (generation != m_generation) + return; + m_impl->logos->logos_execution_zone.get_sequencer_addrAsync( + [this, generation, currentHeight, lastSynced, + callback = std::move(callback)](QString address) mutable { + if (generation != m_generation) + return; + m_impl->logos->logos_execution_zone.list_accountsAsync( + [this, generation, currentHeight, lastSynced, + address = std::move(address), + callback = std::move(callback)]( + QVariantList entries) mutable { + if (generation != m_generation) + return; + + struct SnapshotState { + WalletSnapshot snapshot; + QVector publicReads; + QVector publicFlags; + qsizetype remaining = 0; + SnapshotCallback callback; + }; + auto state = std::make_shared(); + state->snapshot.currentBlockHeight = static_cast( + qMax(0, currentHeight)); + state->snapshot.lastSyncedBlock = static_cast( + qMax(0, lastSynced)); + state->snapshot.sequencerAddress = std::move(address); + state->snapshot.accounts.resize(entries.size()); + state->publicReads.resize(entries.size()); + state->publicFlags.resize(entries.size()); + state->remaining = entries.size(); + state->callback = std::move(callback); + + for (qsizetype index = 0; index < entries.size(); ++index) { + const QVariantMap entry = entries.at(index).toMap(); + const QString accountId = entry + .value(QStringLiteral("account_id")).toString(); + if (entry.isEmpty() || !isHex(accountId, 64)) { + state->snapshot.failure = WalletFailure::ReadFailed; + state->callback(std::move(state->snapshot)); + return; + } + state->snapshot.accounts[index] = WalletAccount { + accountId, + {}, + entry.value(QStringLiteral("is_public"), true).toBool(), + }; + state->publicFlags[index] = + state->snapshot.accounts.at(index).isPublic; + } + + auto finishOne = std::make_shared>(); + *finishOne = [this, generation, state]() mutable { + if (generation != m_generation || --state->remaining > 0) + return; + for (qsizetype index = 0; + index < state->publicReads.size(); ++index) { + if (state->publicFlags.at(index)) + state->snapshot.publicAccountReads.append( + state->publicReads.at(index)); + } + if (state->snapshot.ok()) { + m_snapshot = state->snapshot; + m_snapshotReady = true; + } + state->callback(std::move(state->snapshot)); + }; + + if (entries.isEmpty()) { + state->remaining = 1; + (*finishOne)(); + return; + } + + for (qsizetype index = 0; index < entries.size(); ++index) { + const WalletAccount account = state->snapshot.accounts.at(index); + if (!account.isPublic) { + m_impl->logos->logos_execution_zone.get_balanceAsync( + account.address, false, + [state, finishOne, index](QString balance) { + state->snapshot.accounts[index].balance = + std::move(balance); + (*finishOne)(); + }); + continue; + } + + m_impl->logos->logos_execution_zone.get_account_publicAsync( + account.address, + [this, state, finishOne, index, + accountId = account.address](QString payload) { + const WalletAccountRead read = + parsePublicAccount(accountId, payload); + state->publicReads[index] = read; + applyPublicRead( + state->snapshot.accounts[index], read); + if (read.ok()) { + state->snapshot.accounts[index].balance = + littleEndianU128ToDecimal(read.balanceHex); + (*finishOne)(); + return; + } + m_impl->logos->logos_execution_zone.get_balanceAsync( + accountId, true, + [state, finishOne, index](QString balance) { + state->snapshot.accounts[index].balance = + std::move(balance); + (*finishOne)(); + }); + }); + } + }); + }); + }); + }; + + if (currentHeight > 0) { + m_impl->logos->logos_execution_zone.sync_to_blockAsync( + currentHeight, std::move(afterSync)); + } else { + afterSync(WALLET_FFI_SUCCESS); + } + }); +} + bool LogosWalletProvider::save() const { return m_impl->logos diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h index d0462906..32b0137e 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.h +++ b/apps/shared/wallet/src/LogosWalletProvider.h @@ -2,31 +2,41 @@ #include +#include + #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); ~LogosWalletProvider() override; WalletSession connect(const WalletPaths& paths) override; + void connectAsync(const WalletPaths& paths, SessionCallback callback) override; WalletCreation createWallet(const WalletPaths& paths, const QString& password) override; WalletSnapshot snapshot(bool forceRefresh = false) override; + 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; + void readPublicAccountsAsync(const QStringList& accountIds, + AccountReadsCallback callback) override; WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) override; + void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) override; void disconnect() override; private: bool sharedWalletIsOpen() const; WalletSnapshot loadSnapshot(); + void loadSnapshotAsync(quint64 generation, SnapshotCallback callback); bool save() const; struct Impl; @@ -34,4 +44,6 @@ class LogosWalletProvider final : public WalletProvider { WalletSnapshot m_snapshot; bool m_snapshotReady = false; bool m_connected = false; + quint64 m_generation = 0; + quint64 m_sessionGeneration = 0; }; diff --git a/apps/shared/wallet/src/WalletAccountId.cpp b/apps/shared/wallet/src/WalletAccountId.cpp new file mode 100644 index 00000000..4d3d0639 --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountId.cpp @@ -0,0 +1,112 @@ +#include "WalletAccountId.h" + +#include +#include + +namespace { +constexpr char BASE58_ALPHABET[] = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +constexpr qsizetype ACCOUNT_ID_BYTES = 32; +constexpr qsizetype MIN_BASE58_ACCOUNT_ID_SIZE = 32; +constexpr qsizetype MAX_BASE58_ACCOUNT_ID_SIZE = 44; + +bool isHexCharacter(QChar character) +{ + const ushort value = character.unicode(); + return (value >= '0' && value <= '9') + || (value >= 'a' && value <= 'f') + || (value >= 'A' && value <= 'F'); +} + +int base58Digit(QChar character) +{ + const ushort value = character.unicode(); + if (value > 0x7f) + return -1; + for (int digit = 0; BASE58_ALPHABET[digit] != '\0'; ++digit) { + if (BASE58_ALPHABET[digit] == static_cast(value)) + return digit; + } + return -1; +} +} + +QString walletAccountIdToBase58(const QString& accountId) +{ + if (accountId.size() != 64) + return {}; + for (const QChar character : accountId) { + if (!isHexCharacter(character)) + return {}; + } + + const QByteArray bytes = QByteArray::fromHex(accountId.toLatin1()); + if (bytes.size() != ACCOUNT_ID_BYTES) + return {}; + + qsizetype leadingZeroes = 0; + while (leadingZeroes < bytes.size() && bytes.at(leadingZeroes) == 0) + ++leadingZeroes; + + QVector digits; + digits.reserve(45); + for (const char byte : bytes) { + int carry = static_cast(byte); + for (unsigned char& digit : digits) { + carry += static_cast(digit) * 256; + digit = static_cast(carry % 58); + carry /= 58; + } + while (carry > 0) { + digits.append(static_cast(carry % 58)); + carry /= 58; + } + } + + QString encoded(leadingZeroes, QLatin1Char('1')); + encoded.reserve(leadingZeroes + digits.size()); + for (auto digit = digits.crbegin(); digit != digits.crend(); ++digit) + encoded.append(QLatin1Char(BASE58_ALPHABET[*digit])); + return encoded; +} + +QString walletAccountIdFromBase58(const QString& accountId) +{ + if (accountId.size() < MIN_BASE58_ACCOUNT_ID_SIZE + || accountId.size() > MAX_BASE58_ACCOUNT_ID_SIZE) { + return {}; + } + + qsizetype leadingZeroes = 0; + while (leadingZeroes < accountId.size() + && accountId.at(leadingZeroes) == QLatin1Char('1')) { + ++leadingZeroes; + } + + QVector bytes; + bytes.reserve(ACCOUNT_ID_BYTES); + for (const QChar character : accountId) { + int carry = base58Digit(character); + if (carry < 0) + return {}; + for (unsigned char& byte : bytes) { + carry += static_cast(byte) * 58; + byte = static_cast(carry % 256); + carry /= 256; + } + while (carry > 0) { + bytes.append(static_cast(carry % 256)); + carry /= 256; + } + } + + if (leadingZeroes > ACCOUNT_ID_BYTES + || bytes.size() != ACCOUNT_ID_BYTES - leadingZeroes) { + return {}; + } + + QByteArray decoded(ACCOUNT_ID_BYTES, '\0'); + for (qsizetype index = 0; index < bytes.size(); ++index) + decoded[ACCOUNT_ID_BYTES - index - 1] = static_cast(bytes.at(index)); + return QString::fromLatin1(decoded.toHex()); +} diff --git a/apps/shared/wallet/src/WalletAccountId.h b/apps/shared/wallet/src/WalletAccountId.h new file mode 100644 index 00000000..57fd8a76 --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountId.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +QString walletAccountIdToBase58(const QString& accountId); +QString walletAccountIdFromBase58(const QString& accountId); diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp index 06e94bfd..8b93327a 100644 --- a/apps/shared/wallet/src/WalletAccountModel.cpp +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -1,5 +1,13 @@ #include "WalletAccountModel.h" +#include "WalletAccountId.h" + +#include + +namespace { +const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0')); +} + WalletAccountModel::WalletAccountModel(QObject* parent) : QAbstractListModel(parent) { @@ -21,10 +29,36 @@ QVariant WalletAccountModel::data(const QModelIndex& index, int role) const return account.name; case AddressRole: return account.address; + case DisplayAddressRole: + return account.displayAddress; case BalanceRole: return account.balance; case IsPublicRole: return account.isPublic; + case KindRole: + return account.kind; + case SectionRole: + return account.section; + case ProgramOwnerRole: + return account.programOwner; + case ReadStatusRole: + return account.readStatus; + case ProgramNameRole: + return account.programName; + case AccountTypeRole: + return account.accountType; + case VisibilityRole: + return account.isPublic ? QStringLiteral("public") : QStringLiteral("private"); + case ControlRole: + return QStringLiteral("wallet"); + case CanBePrimaryRole: + return account.canBePrimary; + case IsPrimaryRole: + return account.isPrimary; + case DefinitionIdRole: + return account.definitionId; + case AliasRole: + return account.alias; default: return {}; } @@ -37,25 +71,218 @@ QHash WalletAccountModel::roleNames() const { AddressRole, "address" }, { BalanceRole, "balance" }, { IsPublicRole, "isPublic" }, + { KindRole, "kind" }, + { SectionRole, "section" }, + { ProgramOwnerRole, "programOwner" }, + { ReadStatusRole, "readStatus" }, + { ProgramNameRole, "programName" }, + { AccountTypeRole, "accountType" }, + { VisibilityRole, "visibility" }, + { ControlRole, "control" }, + { CanBePrimaryRole, "canBePrimary" }, + { IsPrimaryRole, "isPrimary" }, + { DefinitionIdRole, "definitionId" }, + { AliasRole, "alias" }, + { DisplayAddressRole, "displayAddress" }, }; } -void WalletAccountModel::replaceAccounts(const QVector& accounts) +void WalletAccountModel::replaceAccounts(const QVector& accounts, + const QHash& aliases, + const QString& primaryAddress) { beginResetModel(); const qsizetype oldCount = m_accounts.size(); m_accounts.clear(); m_accounts.reserve(accounts.size()); - for (qsizetype index = 0; index < accounts.size(); ++index) { - const WalletAccount& account = accounts.at(index); - m_accounts.append({ - QStringLiteral("Account %1").arg(index + 1), - account.address, - account.balance, - account.isPublic, - }); + for (const WalletAccount& account : accounts) { + Entry entry; + entry.alias = aliases.value(account.address); + entry.address = account.address; + entry.displayAddress = walletAccountIdToBase58(account.address); + if (entry.displayAddress.isEmpty()) + entry.displayAddress = account.address; + entry.balance = account.balance; + entry.isPublic = account.isPublic; + entry.programOwner = account.programOwner; + entry.readStatus = account.readStatus; + if (!account.isPublic) { + entry.kind = QStringLiteral("private"); + entry.canBePrimary = true; + } else if (account.readStatus != QStringLiteral("ok")) { + entry.kind = QStringLiteral("unknown"); + } else if (account.programOwner == DEFAULT_PROGRAM_OWNER) { + entry.kind = QStringLiteral("user"); + entry.canBePrimary = true; + } else { + entry.kind = QStringLiteral("program"); + } + entry.section = sectionFor(entry); + entry.isPrimary = account.address == primaryAddress && entry.canBePrimary; + updateEntryName(entry); + m_accounts.append(std::move(entry)); } endResetModel(); if (oldCount != m_accounts.size()) emit countChanged(); } + +bool WalletAccountModel::applyPresentations( + const QVector& presentations) +{ + QHash rowsByAddress; + rowsByAddress.reserve(m_accounts.size()); + for (int row = 0; row < m_accounts.size(); ++row) { + const QString& address = m_accounts.at(row).address; + if (!rowsByAddress.contains(address)) + rowsByAddress.insert(address, row); + } + + int firstChanged = m_accounts.size(); + int lastChanged = -1; + for (const WalletAccountPresentation& presentation : presentations) { + const QString decodedAddress = walletAccountIdFromBase58(presentation.address); + const QString& address = decodedAddress.isEmpty() + ? presentation.address : decodedAddress; + const auto row = rowsByAddress.constFind(address); + if (row == rowsByAddress.cend()) + continue; + const Entry current = m_accounts.at(row.value()); + Entry entry = current; + if (!presentation.kind.isEmpty()) + entry.kind = presentation.kind; + entry.programName = presentation.programName; + entry.accountType = presentation.accountType; + entry.definitionId = presentation.definitionId; + entry.semanticName = presentation.semanticName; + entry.section = sectionFor(entry, presentation.hiddenFromAccounts); + entry.canBePrimary = entry.kind == QStringLiteral("user") + || entry.kind == QStringLiteral("private"); + if (!entry.canBePrimary) + entry.isPrimary = false; + updateEntryName(entry); + if (entry.alias == current.alias + && entry.semanticName == current.semanticName + && entry.name == current.name + && entry.address == current.address + && entry.displayAddress == current.displayAddress + && entry.balance == current.balance + && entry.isPublic == current.isPublic + && entry.kind == current.kind + && entry.section == current.section + && entry.programOwner == current.programOwner + && entry.readStatus == current.readStatus + && entry.programName == current.programName + && entry.accountType == current.accountType + && entry.definitionId == current.definitionId + && entry.canBePrimary == current.canBePrimary + && entry.isPrimary == current.isPrimary) { + continue; + } + m_accounts[row.value()] = std::move(entry); + if (row.value() < firstChanged) + firstChanged = row.value(); + if (row.value() > lastChanged) + lastChanged = row.value(); + } + if (lastChanged < 0) + return false; + emit dataChanged(index(firstChanged), index(lastChanged), { + NameRole, + KindRole, + SectionRole, + ProgramNameRole, + AccountTypeRole, + CanBePrimaryRole, + IsPrimaryRole, + DefinitionIdRole, + }); + return true; +} + +void WalletAccountModel::setAlias(const QString& address, const QString& alias) +{ + const int row = indexOf(address); + if (row < 0) + return; + Entry& entry = m_accounts[row]; + entry.alias = alias; + updateEntryName(entry); + emit dataChanged(index(row), index(row), { NameRole, AliasRole }); +} + +void WalletAccountModel::setPrimaryAddress(const QString& address) +{ + for (int row = 0; row < m_accounts.size(); ++row) { + Entry& entry = m_accounts[row]; + const bool next = entry.address == address && entry.canBePrimary; + if (entry.isPrimary == next) + continue; + entry.isPrimary = next; + emit dataChanged(index(row), index(row), { IsPrimaryRole }); + } +} + +bool WalletAccountModel::contains(const QString& address) const +{ + return indexOf(address) >= 0; +} + +bool WalletAccountModel::canBePrimary(const QString& address) const +{ + const int row = indexOf(address); + return row >= 0 && m_accounts.at(row).canBePrimary; +} + +QString WalletAccountModel::firstAutomaticPrimary() const +{ + for (const Entry& entry : m_accounts) { + if (entry.kind == QStringLiteral("user")) + return entry.address; + } + return {}; +} + +int WalletAccountModel::indexOf(const QString& address) const +{ + for (int row = 0; row < m_accounts.size(); ++row) { + if (m_accounts.at(row).address == address) + return row; + } + return -1; +} + +QString WalletAccountModel::defaultName(const Entry& entry) +{ + if (!entry.accountType.isEmpty()) { + QString name = entry.accountType; + for (qsizetype index = 1; index < name.size(); ++index) { + if (name.at(index).isUpper() && name.at(index - 1).isLower()) + name.insert(index++, QLatin1Char(' ')); + } + return name; + } + if (entry.kind == QStringLiteral("user")) + return QStringLiteral("User account"); + if (entry.kind == QStringLiteral("private")) + return QStringLiteral("Private account"); + if (entry.kind == QStringLiteral("unknown")) + return QStringLiteral("Unknown account"); + return QStringLiteral("Program account"); +} + +QString WalletAccountModel::sectionFor(const Entry& entry, bool hiddenFromAccounts) +{ + if (hiddenFromAccounts || entry.kind == QStringLiteral("token_holding")) + return QStringLiteral("hidden"); + if (entry.kind == QStringLiteral("user") || entry.kind == QStringLiteral("private")) + return QStringLiteral("accounts"); + return QStringLiteral("advanced"); +} + +void WalletAccountModel::updateEntryName(Entry& entry) +{ + entry.name = !entry.alias.isEmpty() + ? entry.alias + : (!entry.semanticName.isEmpty() ? entry.semanticName : defaultName(entry)); +} diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h index c00b0d65..8e21a585 100644 --- a/apps/shared/wallet/src/WalletAccountModel.h +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -1,10 +1,21 @@ #pragma once #include +#include #include #include "WalletProvider.h" +struct WalletAccountPresentation { + QString address; + QString kind; + QString semanticName; + QString programName; + QString accountType; + QString definitionId; + bool hiddenFromAccounts = false; +}; + class WalletAccountModel final : public QAbstractListModel { Q_OBJECT Q_PROPERTY(int count READ count NOTIFY countChanged) @@ -15,6 +26,19 @@ class WalletAccountModel final : public QAbstractListModel { AddressRole, BalanceRole, IsPublicRole, + KindRole, + SectionRole, + ProgramOwnerRole, + ReadStatusRole, + ProgramNameRole, + AccountTypeRole, + VisibilityRole, + ControlRole, + CanBePrimaryRole, + IsPrimaryRole, + DefinitionIdRole, + AliasRole, + DisplayAddressRole, }; Q_ENUM(Role) @@ -24,7 +48,16 @@ class WalletAccountModel final : public QAbstractListModel { QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - void replaceAccounts(const QVector& accounts); + void replaceAccounts(const QVector& accounts, + const QHash& aliases = {}, + const QString& primaryAddress = {}); + bool applyPresentations(const QVector& presentations); + void setAlias(const QString& address, const QString& alias); + void setPrimaryAddress(const QString& address); + bool contains(const QString& address) const; + bool canBePrimary(const QString& address) const; + QString firstAutomaticPrimary() const; + int indexOf(const QString& address) const; int count() const { return m_accounts.size(); } signals: @@ -32,11 +65,27 @@ class WalletAccountModel final : public QAbstractListModel { private: struct Entry { + QString alias; + QString semanticName; QString name; QString address; + QString displayAddress; QString balance; bool isPublic = true; + QString kind; + QString section; + QString programOwner; + QString readStatus; + QString programName; + QString accountType; + QString definitionId; + bool canBePrimary = false; + bool isPrimary = false; }; + static QString defaultName(const Entry& entry); + static QString sectionFor(const Entry& entry, bool hiddenFromAccounts = false); + void updateEntryName(Entry& entry); + QVector m_accounts; }; diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp index 5cffdfd5..9a64970d 100644 --- a/apps/shared/wallet/src/WalletController.cpp +++ b/apps/shared/wallet/src/WalletController.cpp @@ -3,11 +3,17 @@ #include #include +#include #include #include +#include +#include +#include #include #include #include +#include +#include #include #include #include @@ -18,6 +24,10 @@ namespace { const char SETTINGS_ORG[] = "Logos"; const char DISCONNECTED_KEY[] = "disconnected"; const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; +const char WALLET_SETTINGS_GROUP[] = "wallets"; +const char ALIASES_KEY[] = "aliases"; +const char PRIMARY_ACCOUNT_KEY[] = "primaryAccount"; +constexpr qsizetype MAX_ALIAS_LENGTH = 40; QString toLocalPath(const QString& path) { @@ -25,6 +35,26 @@ QString toLocalPath(const QString& path) return QUrl::fromUserInput(path).toLocalFile(); return path; } + +QString configuredSequencer(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return {}; + return document.object().value(QStringLiteral("sequencer_addr")).toString(); +} + +QString canonicalStoragePath(const QString& path) +{ + const QFileInfo info(path); + const QString canonical = info.canonicalFilePath(); + return canonical.isEmpty() + ? QDir::cleanPath(info.absoluteFilePath()) + : canonical; +} } WalletController::WalletController(WalletProvider& wallet, @@ -38,7 +68,10 @@ WalletController::WalletController(WalletProvider& wallet, m_reachabilityTimer(new QTimer(this)) { m_state.walletHome = defaultWalletHome(); + m_state.configPath = defaultConfigPath(); + m_state.storagePath = defaultStoragePath(); m_state.walletExists = QFileInfo::exists(defaultStoragePath()); + m_state.sequencerAddress = configuredSequencer(defaultConfigPath()); m_reachabilityTimer->setInterval(10000); connect(m_reachabilityTimer, &QTimer::timeout, @@ -65,12 +98,56 @@ QString WalletController::defaultStoragePath() const return m_state.walletHome + QStringLiteral("/storage.json"); } +void WalletController::setDefaultSequencerAddress(const QString& address) +{ + const QString normalized = address.trimmed(); + const QUrl endpoint(normalized); + const QString scheme = endpoint.scheme().toLower(); + if (endpoint.isValid() + && !endpoint.host().isEmpty() + && (scheme == QStringLiteral("http") || scheme == QStringLiteral("https"))) { + m_defaultSequencerAddress = normalized; + } else { + m_defaultSequencerAddress.clear(); + } +} + +bool WalletController::seedDefaultWalletConfig(const QString& configPath) const +{ + if (m_defaultSequencerAddress.isEmpty() || QFileInfo::exists(configPath)) + return true; + + const QFileInfo configInfo(configPath); + if (!QDir().mkpath(configInfo.absolutePath())) { + qWarning() << "WalletController: failed to create wallet configuration directory"; + return false; + } + + QSaveFile config(configPath); + if (!config.open(QIODevice::WriteOnly)) { + qWarning() << "WalletController: failed to open wallet configuration"; + return false; + } + + const QByteArray contents = QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), m_defaultSequencerAddress }, + { QStringLiteral("seq_poll_timeout"), QStringLiteral("12s") }, + { QStringLiteral("seq_tx_poll_max_blocks"), 5 }, + { QStringLiteral("seq_poll_max_retries"), 5 }, + { QStringLiteral("seq_block_poll_max_amount"), 100 }, + }).toJson(QJsonDocument::Compact); + if (config.write(contents) != contents.size() || !config.commit()) { + qWarning() << "WalletController: failed to save wallet configuration"; + return false; + } + return true; +} + void WalletController::start() { if (m_started) return; m_started = true; - m_reachabilityTimer->start(); QTimer::singleShot(0, this, &WalletController::openOnStartup); } @@ -83,31 +160,76 @@ void WalletController::openOnStartup() const QString config = defaultConfigPath(); const QString storage = defaultStoragePath(); - const WalletSession session = m_wallet.connect({ config, storage }); - if (session.failure == WalletFailure::WalletMissing) - return; - if (!session.ok()) { - qWarning() << "WalletController: wallet connection failed" - << walletFailureCode(session.failure); - return; + beginOpen(config, storage); +} + +bool WalletController::beginOpen(const QString& config, const QString& storage) +{ + if (m_state.syncStatus == QStringLiteral("opening") + || m_state.syncStatus == QStringLiteral("syncing")) { + return false; } + const quint64 generation = ++m_operationGeneration; m_state.configPath = config; m_state.storagePath = storage; - m_state.walletExists = QFileInfo::exists(storage) || session.adopted; - m_state.isWalletOpen = true; - applySnapshot(session.snapshot); + m_state.syncStatus = QStringLiteral("opening"); + m_state.syncError.clear(); + const QString endpoint = configuredSequencer(config); + if (!endpoint.isEmpty()) + m_state.sequencerAddress = endpoint; + emit stateChanged(); + + QTimer::singleShot(0, this, [this, generation]() { + if (generation == m_operationGeneration + && m_state.syncStatus == QStringLiteral("opening")) { + m_state.syncStatus = QStringLiteral("syncing"); + emit stateChanged(); + } + }); + const QPointer guard(this); + m_wallet.connectAsync({ config, storage }, + [guard, generation, config, storage](WalletSession session) { + if (!guard || generation != guard->m_operationGeneration) + return; + if (session.failure == WalletFailure::WalletMissing) { + guard->m_state.syncStatus = QStringLiteral("closed"); + guard->m_state.walletExists = false; + emit guard->stateChanged(); + return; + } + if (!session.ok()) { + qWarning() << "WalletController: wallet connection failed" + << walletFailureCode(session.failure); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(session.failure); + emit guard->stateChanged(); + return; + } + + guard->m_state.configPath = config; + guard->m_state.storagePath = storage; + guard->m_state.walletExists = QFileInfo::exists(storage) || session.adopted; + guard->m_state.isWalletOpen = true; + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(session.snapshot); + }); + return true; } QString WalletController::createDefaultWallet(const QString& password) { - return createWallet(defaultConfigPath(), defaultStoragePath(), password); + const QString config = defaultConfigPath(); + if (!seedDefaultWalletConfig(config)) + return {}; + return createWallet(config, defaultStoragePath(), password); } QString WalletController::createWallet(const QString& configPath, const QString& storagePath, const QString& password) { + const quint64 generation = ++m_operationGeneration; const QString config = toLocalPath(configPath); const QString storage = toLocalPath(storagePath); const WalletCreation creation = m_wallet.createWallet( @@ -117,20 +239,50 @@ QString WalletController::createWallet(const QString& configPath, << walletFailureCode(creation.failure); return {}; } + stopReachability(); m_state.configPath = config; m_state.storagePath = storage; - m_state.walletExists = true; QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); if (!creation.ok()) { qWarning() << "WalletController: wallet creation failed" << walletFailureCode(creation.failure); + m_state.walletExists = QFileInfo::exists(storage); + m_state.isWalletOpen = false; + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(creation.failure); emit stateChanged(); return creation.mnemonic; } + m_state.walletExists = true; m_state.isWalletOpen = true; - applySnapshot(creation.snapshot); + m_state.syncStatus = QStringLiteral("syncing"); + m_state.syncError.clear(); + m_accountModel->replaceAccounts({}); + emit stateChanged(); + + const QPointer guard(this); + QTimer::singleShot(0, this, [guard, generation]() { + if (!guard || generation != guard->m_operationGeneration) + return; + guard->m_wallet.snapshotAsync(true, + [guard, generation](WalletSnapshot snapshot) { + if (!guard || generation != guard->m_operationGeneration) + return; + if (snapshot.ok()) { + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(snapshot); + return; + } + + qWarning() << "WalletController: initial wallet sync failed" + << walletFailureCode(snapshot.failure); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(snapshot.failure); + emit guard->stateChanged(); + }); + }); return creation.mnemonic; } @@ -140,29 +292,72 @@ bool WalletController::open() ? defaultConfigPath() : m_state.configPath; const QString storage = m_state.storagePath.isEmpty() ? defaultStoragePath() : m_state.storagePath; - const WalletSession session = m_wallet.connect({ config, storage }); - if (!session.ok()) { - qWarning() << "WalletController: wallet open failed" - << walletFailureCode(session.failure); - return false; - } - - m_state.configPath = config; - m_state.storagePath = storage; - m_state.walletExists = true; - m_state.isWalletOpen = true; QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); - applySnapshot(session.snapshot); - return true; + return beginOpen(config, storage); } void WalletController::disconnect() { + ++m_operationGeneration; + stopReachability(); m_wallet.disconnect(); m_state.isWalletOpen = false; + m_state.syncStatus = QStringLiteral("closed"); + m_state.syncError.clear(); m_accountModel->replaceAccounts({}); QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); emit stateChanged(); + emit snapshotChanged(); +} + +bool WalletController::setAccountAlias(const QString& address, const QString& alias) +{ + if (!m_accountModel->contains(address)) + return false; + const QString normalized = alias.trimmed(); + if (normalized.size() > MAX_ALIAS_LENGTH) + return false; + if (normalized.isEmpty()) + m_aliases.remove(address); + else + m_aliases.insert(address, normalized); + m_accountModel->setAlias(address, normalized); + storeAliases(m_aliases); + updatePrimaryState(m_state.primaryAccountAddress); + emit stateChanged(); + return true; +} + +bool WalletController::setPrimaryAccount(const QString& address) +{ + if (!m_accountModel->canBePrimary(address)) + return false; + m_accountModel->setPrimaryAddress(address); + storePrimaryAccount(address); + updatePrimaryState(address); + emit stateChanged(); + return true; +} + +void WalletController::applyAccountPresentations( + const QVector& presentations) +{ + if (!m_accountModel->applyPresentations(presentations)) + return; + + const QString previousPrimary = m_state.primaryAccountAddress; + const QString previousPrimaryName = m_state.primaryAccountName; + QString primary = m_state.primaryAccountAddress; + if (!m_accountModel->canBePrimary(primary)) + primary = m_accountModel->firstAutomaticPrimary(); + m_accountModel->setPrimaryAddress(primary); + if (primary != previousPrimary) + storePrimaryAccount(primary); + updatePrimaryState(primary); + if (m_state.primaryAccountAddress != previousPrimary + || m_state.primaryAccountName != previousPrimaryName) { + emit stateChanged(); + } } QString WalletController::createAccount(bool isPublic) @@ -174,23 +369,42 @@ QString WalletController::createAccount(bool isPublic) return {}; } if (creation.snapshot.ok()) { + m_state.syncStatus = QStringLiteral("ready"); + m_state.syncError.clear(); applySnapshot(creation.snapshot); } else { qWarning() << "WalletController: account refresh failed" << walletFailureCode(creation.snapshot.failure); + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(creation.snapshot.failure); + emit stateChanged(); } return creation.accountId; } void WalletController::refresh() { - const WalletSnapshot next = m_wallet.snapshot(true); - if (next.ok()) { - applySnapshot(next); - } else { - qWarning() << "WalletController: wallet refresh failed" - << walletFailureCode(next.failure); - } + if (!m_state.isWalletOpen || m_state.syncStatus == QStringLiteral("syncing")) + return; + const quint64 generation = ++m_operationGeneration; + m_state.syncStatus = QStringLiteral("syncing"); + m_state.syncError.clear(); + emit stateChanged(); + const QPointer guard(this); + m_wallet.snapshotAsync(true, [guard, generation](WalletSnapshot next) { + if (!guard || generation != guard->m_operationGeneration) + return; + if (next.ok()) { + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(next); + } else { + qWarning() << "WalletController: wallet refresh failed" + << walletFailureCode(next.failure); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(next.failure); + emit guard->stateChanged(); + } + }); } QString WalletController::balance(const QString& accountId, bool isPublic) @@ -205,24 +419,125 @@ QString WalletController::balance(const QString& accountId, bool isPublic) void WalletController::applySnapshot(const WalletSnapshot& snapshot) { - m_accountModel->replaceAccounts(snapshot.accounts); + m_snapshot = snapshot; + m_aliases = loadAliases(); + QString primary = loadPrimaryAccount(); + m_accountModel->replaceAccounts(snapshot.accounts, m_aliases, primary); + if (!m_accountModel->canBePrimary(primary)) + primary = m_accountModel->firstAutomaticPrimary(); + m_accountModel->setPrimaryAddress(primary); + storePrimaryAccount(primary); + updatePrimaryState(primary); m_state.lastSyncedBlock = static_cast(snapshot.lastSyncedBlock); m_state.currentBlockHeight = static_cast(snapshot.currentBlockHeight); - m_state.sequencerAddress = snapshot.sequencerAddress; + if (!snapshot.sequencerAddress.isEmpty()) + m_state.sequencerAddress = snapshot.sequencerAddress; + emit snapshotChanged(); emit stateChanged(); + if (!m_reachabilityTimer->isActive()) + m_reachabilityTimer->start(); checkReachability(); } +QString WalletController::walletSettingsGroup() const +{ + const QByteArray hash = QCryptographicHash::hash( + canonicalStoragePath(m_state.storagePath).toUtf8(), + QCryptographicHash::Sha256).toHex(); + return QStringLiteral("%1/%2") + .arg(QString::fromLatin1(WALLET_SETTINGS_GROUP), QString::fromLatin1(hash)); +} + +QHash WalletController::loadAliases() const +{ + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + const QVariantMap stored = settings.value(ALIASES_KEY).toMap(); + QHash aliases; + for (auto iterator = stored.cbegin(); iterator != stored.cend(); ++iterator) { + const QString alias = iterator.value().toString().trimmed(); + if (!alias.isEmpty() && alias.size() <= MAX_ALIAS_LENGTH) + aliases.insert(iterator.key(), alias); + } + return aliases; +} + +QString WalletController::loadPrimaryAccount() const +{ + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + return settings.value(PRIMARY_ACCOUNT_KEY).toString(); +} + +void WalletController::storeAliases(const QHash& aliases) const +{ + QVariantMap stored; + for (auto iterator = aliases.cbegin(); iterator != aliases.cend(); ++iterator) + stored.insert(iterator.key(), iterator.value()); + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + settings.setValue(ALIASES_KEY, stored); +} + +void WalletController::storePrimaryAccount(const QString& address) const +{ + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + if (address.isEmpty()) + settings.remove(PRIMARY_ACCOUNT_KEY); + else + settings.setValue(PRIMARY_ACCOUNT_KEY, address); +} + +void WalletController::updatePrimaryState(const QString& address) +{ + m_state.primaryAccountAddress = address; + m_state.primaryAccountName.clear(); + const int row = m_accountModel->indexOf(address); + if (row >= 0) { + m_state.primaryAccountName = m_accountModel->data( + m_accountModel->index(row), WalletAccountModel::NameRole).toString(); + } +} + +void WalletController::stopReachability() +{ + m_reachabilityTimer->stop(); + ++m_reachabilityGeneration; + if (m_reachabilityReply) { + QNetworkReply* reply = m_reachabilityReply; + m_reachabilityReply = nullptr; + m_reachabilityEndpoint.clear(); + reply->abort(); + } +} + void WalletController::checkReachability() { if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty()) return; - QNetworkRequest request{QUrl(m_state.sequencerAddress)}; + const QString endpoint = m_state.sequencerAddress; + if (m_reachabilityReply && endpoint == m_reachabilityEndpoint) + return; + + const quint64 generation = ++m_reachabilityGeneration; + if (m_reachabilityReply) + m_reachabilityReply->abort(); + QNetworkRequest request{QUrl(endpoint)}; request.setTransferTimeout(4000); QNetworkReply* reply = m_network->get(request); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - if (!m_state.isWalletOpen) { + m_reachabilityReply = reply; + m_reachabilityEndpoint = endpoint; + connect(reply, &QNetworkReply::finished, this, + [this, reply, generation, endpoint]() { + if (m_reachabilityReply == reply) { + m_reachabilityReply = nullptr; + m_reachabilityEndpoint.clear(); + } + if (!m_state.isWalletOpen + || generation != m_reachabilityGeneration + || endpoint != m_state.sequencerAddress) { reply->deleteLater(); return; } diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h index c27f9cef..e30f733e 100644 --- a/apps/shared/wallet/src/WalletController.h +++ b/apps/shared/wallet/src/WalletController.h @@ -1,13 +1,17 @@ #pragma once #include +#include #include +#include #include "WalletProvider.h" class QNetworkAccessManager; +class QNetworkReply; class QTimer; class WalletAccountModel; +struct WalletAccountPresentation; struct WalletUiState { bool isWalletOpen = false; @@ -19,6 +23,15 @@ struct WalletUiState { int currentBlockHeight = 0; QString sequencerAddress; bool sequencerReachable = true; + QString syncStatus = QStringLiteral("closed"); + QString syncError; + QString primaryAccountAddress; + QString primaryAccountName; + + bool canSubmit() const + { + return isWalletOpen && syncStatus == QStringLiteral("ready"); + } }; class WalletController final : public QObject { @@ -33,8 +46,10 @@ class WalletController final : public QObject { WalletAccountModel* accountModel() const { return m_accountModel; } const WalletUiState& state() const { return m_state; } + const WalletSnapshot& snapshot() const { return m_snapshot; } void start(); + void setDefaultSequencerAddress(const QString& address); QString createAccount(bool isPublic); void refresh(); QString balance(const QString& accountId, bool isPublic); @@ -44,24 +59,45 @@ class WalletController final : public QObject { const QString& password); bool open(); void disconnect(); + bool setAccountAlias(const QString& address, const QString& alias); + bool setPrimaryAccount(const QString& address); + void applyAccountPresentations( + const QVector& presentations); signals: void stateChanged(); + void snapshotChanged(); private: static QString defaultWalletHome(); QString defaultConfigPath() const; QString defaultStoragePath() const; + bool seedDefaultWalletConfig(const QString& configPath) const; void openOnStartup(); + bool beginOpen(const QString& config, const QString& storage); void applySnapshot(const WalletSnapshot& snapshot); void checkReachability(); + void stopReachability(); + QString walletSettingsGroup() const; + QHash loadAliases() const; + QString loadPrimaryAccount() const; + void storeAliases(const QHash& aliases) const; + void storePrimaryAccount(const QString& address) const; + void updatePrimaryState(const QString& address); WalletProvider& m_wallet; QString m_settingsApplication; WalletUiState m_state; + WalletSnapshot m_snapshot; + QHash m_aliases; WalletAccountModel* m_accountModel; QNetworkAccessManager* m_network; + QNetworkReply* m_reachabilityReply = nullptr; + QString m_reachabilityEndpoint; + QString m_defaultSequencerAddress; QTimer* m_reachabilityTimer; bool m_started = false; + quint64 m_operationGeneration = 0; + quint64 m_reachabilityGeneration = 0; }; diff --git a/apps/shared/wallet/src/WalletProvider.h b/apps/shared/wallet/src/WalletProvider.h index a7814caa..434b5635 100644 --- a/apps/shared/wallet/src/WalletProvider.h +++ b/apps/shared/wallet/src/WalletProvider.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -38,6 +39,9 @@ struct WalletAccount { QString address; QString balance; bool isPublic = true; + QString readStatus; + QString programOwner; + QString dataHex; }; struct WalletSnapshot { @@ -92,16 +96,29 @@ struct WalletSubmission { class WalletProvider { public: + using SessionCallback = std::function; + using SnapshotCallback = std::function; + using AccountReadsCallback = std::function)>; + using AccountCreationCallback = std::function; + using SubmissionCallback = std::function; + virtual ~WalletProvider() = default; virtual WalletSession connect(const WalletPaths& paths) = 0; + virtual void connectAsync(const WalletPaths& paths, SessionCallback callback) = 0; virtual WalletCreation createWallet(const WalletPaths& paths, const QString& password) = 0; virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0; + 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 void readPublicAccountsAsync(const QStringList& accountIds, + AccountReadsCallback callback) = 0; virtual WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) = 0; + virtual void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) = 0; virtual void disconnect() = 0; }; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index 9701d6b5..43d839f0 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -1,15 +1,23 @@ +#include #include #include #include +#include #include #include #include +#include +#include #include #include #include +#include +#include + #include "FakeWalletProvider.h" #include "LogosWalletProvider.h" +#include "WalletAccountId.h" #include "WalletAccountModel.h" #include "WalletController.h" #include "logos_sdk.h" @@ -17,7 +25,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 +73,7 @@ QVariantMap accountEntry(const QString& id, bool isPublic) { QStringLiteral("is_public"), isPublic }, }; } + } class LogosWalletProviderTest : public QObject { @@ -47,18 +82,36 @@ 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 walletMutationsUseAsyncSdk(); + void staleAsyncMutationCannotCrossSession(); + void destroyedProviderIgnoresLateMutation(); void exposesStableAccountModelRoles(); + void encodesAccountIdsForDisplay(); + 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 +137,16 @@ 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(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 +154,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 +190,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 +198,92 @@ 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); + + bool batchRead = false; + provider.readPublicAccountsAsync( + { ACCOUNT_A, ACCOUNT_B }, + [&batchRead](QVector reads) { + batchRead = reads.size() == 2 + && reads.at(0).ok() + && !reads.at(1).ok(); + }); + QVERIFY(batchRead); +} + +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 +297,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 +370,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 +402,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 +413,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() @@ -333,24 +480,224 @@ 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; 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.data(model.index(1), WalletAccountModel::DisplayAddressRole).toString(), + walletAccountIdToBase58(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 presentations { + { + ACCOUNT_A, + QStringLiteral("program"), + {}, + QStringLiteral("System"), + QStringLiteral("UserAccount"), + {}, + false, + }, + { + walletAccountIdToBase58(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")); + 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::encodesAccountIdsForDisplay() +{ + QCOMPARE(walletAccountIdToBase58( + QStringLiteral("00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb")), + QStringLiteral("14tAtixMByFyJrcZVyWibitnijLgd59PfyrjdnYzo8La")); + QCOMPARE(walletAccountIdFromBase58( + QStringLiteral("14tAtixMByFyJrcZVyWibitnijLgd59PfyrjdnYzo8La")), + QStringLiteral("00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb")); + QCOMPARE(walletAccountIdToBase58(QString(64, QLatin1Char('0'))), + QString(32, QLatin1Char('1'))); + QCOMPARE(walletAccountIdFromBase58(QString(32, QLatin1Char('1'))), + QString(64, QLatin1Char('0'))); + QVERIFY(walletAccountIdToBase58(QStringLiteral("not-an-account-id")).isEmpty()); + QVERIFY(walletAccountIdFromBase58(QString(32, QLatin1Char('0'))).isEmpty()); + QVERIFY(walletAccountIdFromBase58(QString(45, QLatin1Char('1'))).isEmpty()); +} + +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 +766,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 +939,164 @@ void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() QVERIFY(controller.open()); QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000); + auto* timer = controller.findChild(); + QVERIFY(timer); + QVERIFY(timer->isActive()); controller.disconnect(); + QVERIFY(!timer->isActive()); finished.clear(); - auto* timer = controller.findChild(); - QVERIFY(timer); 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 callbackLifetime; + { + auto lifetime = std::make_shared(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(provider, settingsApplication); + QVERIFY(controller->open()); + } + provider.finishConnect(); + + provider.deferAsync = false; + { + auto controller = std::make_unique(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(); + 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" diff --git a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h index c1927f43..87cf30f4 100644 --- a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h +++ b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h @@ -6,6 +6,9 @@ #include #include +#include +#include + class LogosAPI; class FakeExecutionZone { @@ -30,6 +33,10 @@ class FakeExecutionZone { int listCalls = 0; int publicReadCalls = 0; int submitCalls = 0; + bool deferPublicAccountCreation = false; + bool deferSubmission = false; + std::function pendingPublicAccountCreation; + std::function pendingSubmission; QString openedConfig; QString openedStorage; QString createdConfig; @@ -48,6 +55,13 @@ class FakeExecutionZone { return openResult; } + void openAsync(const QString& config, + const QString& storage, + std::function callback) + { + callback(open(config, storage)); + } + QString create_new(const QString& config, const QString& storage, const QString& password) @@ -64,36 +78,80 @@ class FakeExecutionZone { return saveResult; } + void saveAsync(std::function callback) { callback(save()); } + QString create_account_public() { return publicAccountId; } QString create_account_private() { return privateAccountId; } + void create_account_publicAsync(std::function callback) + { + if (deferPublicAccountCreation) + pendingPublicAccountCreation = std::move(callback); + else + callback(create_account_public()); + } + void create_account_privateAsync(std::function callback) + { + callback(create_account_private()); + } int get_last_synced_block() const { return lastSyncedBlock; } int get_current_block_height() const { return currentBlockHeight; } + void get_last_synced_blockAsync(std::function callback) + { + callback(get_last_synced_block()); + } + void get_current_block_heightAsync(std::function callback) + { + callback(get_current_block_height()); + } int sync_to_block(quint64) { ++syncCalls; return syncResult; } + void sync_to_blockAsync(int blockId, std::function callback) + { + callback(sync_to_block(static_cast(blockId))); + } QString get_sequencer_addr() const { return sequencerAddress; } + void get_sequencer_addrAsync(std::function callback) + { + callback(get_sequencer_addr()); + } QVariantList list_accounts() { ++listCalls; return accounts; } + void list_accountsAsync(std::function 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 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 callback) + { + callback(get_balance(accountId, isPublic)); + } QString send_generic_public_transaction( const QStringList& accountIds, @@ -108,6 +166,38 @@ class FakeExecutionZone { submittedProgramId = programId; return transactionResponse; } + + void send_generic_public_transactionAsync( + const QStringList& accountIds, + const QVariantList& signingRequirements, + const QVariant& instruction, + const QString& programId, + std::function 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 { diff --git a/apps/shared/wallet/tests/qml/tst_CopyButton.qml b/apps/shared/wallet/tests/qml/tst_CopyButton.qml new file mode 100644 index 00000000..32c9aba9 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_CopyButton.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtTest + +import Logos.Wallet as Wallet + +Item { + id: root + + width: 360 + height: 240 + + Component { + id: copyButtonComponent + + Wallet.CopyButton {} + } + + Component { + id: clipboardSinkComponent + + TextEdit {} + } + + SignalSpy { + id: copyRequestedSpy + } + + TestCase { + name: "CopyButton" + when: windowShown + + function test_copiesTextAndRetainsCopySignal() { + const value = "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE" + const copyButton = createTemporaryObject(copyButtonComponent, root, { + "copyText": value, + "copyLabel": "Copy address" + }) + const sink = createTemporaryObject(clipboardSinkComponent, root) + verify(copyButton, "Copy button exists") + verify(sink, "Clipboard sink exists") + compare(copyButton.implicitWidth, 36) + compare(copyButton.implicitHeight, 36) + + copyRequestedSpy.target = copyButton + copyRequestedSpy.signalName = "copyRequested" + copyRequestedSpy.clear() + copyButton.click() + + verify(copyButton.copied) + compare(copyRequestedSpy.count, 1) + sink.paste() + tryCompare(sink, "text", value) + copyRequestedSpy.target = null + } + } +} diff --git a/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml index 898a2450..01768e80 100644 --- a/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml +++ b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml @@ -103,6 +103,38 @@ Item { tryCompare(dialog, "opened", false) } + function test_activityStateDoesNotBlockCancellation() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + dialog.openWithSnapshot({ amount: "5" }) + tryCompare(dialog, "opened", true) + + dialog.activityBusy = true + const cancelButton = findChild(dialog, "transactionCancelButton") + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(cancelButton.enabled) + verify(!confirmButton.enabled) + + dialog.cancel() + tryCompare(dialog, "opened", false) + } + + function test_cancelUsesTheConfirmationButtonShape() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + dialog.roundedCancelButton = true + dialog.openWithSnapshot({ amount: "5" }) + tryCompare(dialog, "opened", true) + + const cancelButtonLoader = findChild(dialog, "transactionCancelButtonLoader") + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(cancelButtonLoader) + tryVerify(function() { + return cancelButtonLoader.item + && cancelButtonLoader.item.background.radius === confirmButton.background.radius + }) + } + function test_keepsActionsInsideShortViewport() { const viewport = createTemporaryObject(viewportComponent, root) verify(viewport, "Short viewport exists") diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml index 157a0bcd..71f480b2 100644 --- a/apps/shared/wallet/tests/qml/tst_WalletControl.qml +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -13,32 +13,65 @@ Item { QtObject { property bool isWalletOpen: false property bool walletExists: true + property bool completeOpenImmediately: true + property bool createWalletFails: false + property bool createWalletRefreshFails: false + property bool accountRefreshFails: false property string walletHome: "/wallet" + property string walletSyncStatus: "closed" + property string walletSyncError: "" + property bool deferOpen: false property int openCalls: 0 property int createCalls: 0 property int publicAccountCalls: 0 property int privateAccountCalls: 0 property int disconnectCalls: 0 + property int primaryAccountCalls: 0 + property int aliasCalls: 0 + property string primaryAccountAddress: "" + property string primaryAccountName: "" + property string activeNetwork: "testnet" + property string networkStatus: "ready" + property string assetStatus: "ready" + property string assetError: "" + property var assets: [] function openExisting() { openCalls++ - isWalletOpen = true + if (deferOpen) { + walletSyncStatus = "opening" + } else { + isWalletOpen = true + walletSyncStatus = "ready" + } return true } function createNewDefault(_password) { createCalls++ + if (createWalletFails) + return "" isWalletOpen = true + walletSyncStatus = createWalletRefreshFails ? "error" : "ready" + walletSyncError = createWalletRefreshFails ? "read_failed" : "" return "alpha beta gamma" } function createAccountPublic() { publicAccountCalls++ + if (accountRefreshFails) { + walletSyncStatus = "error" + walletSyncError = "read_failed" + } return "a".repeat(64) } function createAccountPrivate() { privateAccountCalls++ + if (accountRefreshFails) { + walletSyncStatus = "error" + walletSyncError = "read_failed" + } return "b".repeat(64) } @@ -46,6 +79,17 @@ Item { disconnectCalls++ isWalletOpen = false } + + function setPrimaryAccount(address) { + primaryAccountCalls++ + primaryAccountAddress = address + return true + } + + function setAccountAlias(_address, _alias) { + aliasCalls++ + return true + } } } @@ -115,7 +159,7 @@ Item { const model = createTemporaryObject(modelComponent, root) verify(model, "Account model exists") for (const account of accounts || []) - model.append(account) + model.append(accountData(account)) const control = createTemporaryObject(controlComponent, root, { wallet: backend, accountModel: model @@ -124,6 +168,24 @@ Item { return { backend, model, control } } + function accountData(account) { + return { + name: account.name || "Account", + alias: account.alias || "", + address: account.address || "", + displayAddress: account.displayAddress || account.address || "", + balance: account.balance || "0", + isPublic: account.isPublic === true, + kind: account.kind || (account.isPublic === false ? "private" : "user"), + section: account.section || "accounts", + programName: account.programName || "", + accountType: account.accountType || "", + visibility: account.visibility || (account.isPublic === false ? "private" : "public"), + canBePrimary: account.canBePrimary === undefined ? true : account.canBePrimary, + isPrimary: account.isPrimary === true + } + } + function test_opensExistingWallet() { const fixture = createControl({ walletExists: true }, []) const connectButton = findChild(fixture.control, "walletConnectButton") @@ -133,6 +195,19 @@ Item { tryCompare(fixture.control, "connected", true) } + function test_surfacesDeferredOpenFailure() { + const fixture = createControl({ walletExists: true, deferOpen: true }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + compare(fixture.backend.openCalls, 1) + compare(fixture.control.syncStatus, "opening") + + fixture.backend.walletSyncStatus = "error" + fixture.backend.walletSyncError = "open_failed" + const dialog = findChild(fixture.control, "walletMessageDialog") + tryCompare(dialog, "opened", true) + verify(dialog.message.includes("open_failed")) + } + function test_requiresSeedBackupAcknowledgement() { const fixture = createControl({ walletExists: false }, []) mouseClick(findChild(fixture.control, "walletConnectButton")) @@ -165,6 +240,42 @@ Item { tryCompare(dialog, "opened", false) } + function test_showsWalletCreationFailure() { + const fixture = createControl({ walletExists: false, createWalletFails: true }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + const dialog = findChild(fixture.control, "createWalletDialog") + tryCompare(dialog, "opened", true) + findChild(dialog, "walletPasswordField").text = "secret" + findChild(dialog, "walletConfirmPasswordField").text = "secret" + findChild(dialog, "createWalletButton").clicked() + compare(fixture.backend.createCalls, 1) + compare(dialog.mnemonic, "") + compare(dialog.errorText, "Wallet could not be created.") + verify(dialog.opened) + } + + function test_warnsWhenCreatedWalletCannotRefresh() { + const fixture = createControl({ + walletExists: false, + createWalletRefreshFails: true + }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + const dialog = findChild(fixture.control, "createWalletDialog") + tryCompare(dialog, "opened", true) + findChild(dialog, "walletPasswordField").text = "secret" + findChild(dialog, "walletConfirmPasswordField").text = "secret" + findChild(dialog, "createWalletButton").clicked() + tryCompare(dialog, "mnemonic", "alpha beta gamma") + const message = findChild(fixture.control, "walletMessageDialog") + verify(!message.opened) + mouseClick(findChild(dialog, "walletBackupAcknowledgement")) + mouseClick(findChild(dialog, "walletContinueButton")) + + tryCompare(message, "opened", true) + compare(message.message, + "Wallet was created, but could not be refreshed. Reconnect the wallet to refresh it.") + } + function test_clampsSelectionAndDisconnectsLocally() { const fixture = createControl({ isWalletOpen: true }, [ { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, @@ -173,12 +284,12 @@ Item { fixture.control.selectedIndex = 1 compare(fixture.control.selectedAddress, "b".repeat(64)) fixture.model.clear() - tryCompare(fixture.control, "selectedIndex", 0) + tryCompare(fixture.control, "selectedIndex", -1) compare(fixture.control.selectedAddress, "") - fixture.model.append({ + fixture.model.append(accountData({ name: "One", address: "a".repeat(64), balance: "10", isPublic: true - }) + })) mouseClick(findChild(fixture.control, "walletAccountButton")) const disconnectButton = findChild(fixture.control, "walletDisconnectButton") tryVerify(function() { return disconnectButton.visible }) @@ -187,6 +298,31 @@ Item { tryCompare(fixture.control, "connected", false) } + function test_waitsForPrimaryDelegateBeforeShowingAccountType() { + const address = "a".repeat(64) + const fixture = createControl({ + isWalletOpen: true, + primaryAccountAddress: address, + primaryAccountName: "Primary" + }, []) + mouseClick(findChild(fixture.control, "walletAccountButton")) + + const accountType = findChild(fixture.control, "walletPrimaryAccountType") + verify(accountType, "Primary account type exists") + verify(!accountType.visible, "Account type waits for its selected delegate") + + fixture.model.append(accountData({ + name: "Primary", + address: address, + balance: "10", + isPublic: true, + isPrimary: true + })) + tryCompare(fixture.control, "selectedAddress", address) + tryCompare(accountType, "visible", true) + compare(accountType.text, "Public user account") + } + function test_connectedButtonClosesOpenMenu() { const fixture = createControl({ isWalletOpen: true }, [ { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } @@ -200,6 +336,53 @@ Item { tryCompare(menu, "opened", false) } + function test_walletMenuClosesWithEscape() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + const accountButton = findChild(fixture.control, "walletAccountButton") + const menu = findChild(fixture.control, "walletMenu") + + mouseClick(accountButton) + tryCompare(menu, "opened", true) + keyClick(Qt.Key_Escape) + tryCompare(menu, "opened", false) + } + + function test_createAccountDialogOwnsKeyboardFocus() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + + const backButton = findChild(fixture.control, "walletAccountsBackButton") + const addButton = findChild(fixture.control, "walletAddAccountButton") + verify(backButton && addButton, "Account controls exist") + mouseClick(addButton) + + const dialog = findChild(fixture.control, "createAccountDialog") + const privateSwitch = findChild(dialog, "privateAccountSwitch") + tryCompare(dialog, "opened", true) + tryVerify(function() { return privateSwitch.activeFocus }) + for (let index = 0; index < 6; ++index) { + keyClick(Qt.Key_Tab) + verify(!backButton.activeFocus, "Focus remains inside the dialog") + } + keyClick(Qt.Key_Escape) + tryCompare(dialog, "opened", false) + } + + function test_walletMessageDialogClosesWithEscape() { + const fixture = createControl({ isWalletOpen: true }, []) + const dialog = findChild(fixture.control, "walletMessageDialog") + + dialog.open() + tryCompare(dialog, "opened", true) + keyClick(Qt.Key_Escape) + tryCompare(dialog, "opened", false) + } + function test_openMenuTracksControlMovement() { const fixture = createControl({ isWalletOpen: true }, [ { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } @@ -226,8 +409,20 @@ Item { function test_selectsAccount() { const fixture = createControl({ isWalletOpen: true }, [ - { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, - { name: "Two", address: "b".repeat(64), balance: "20", isPublic: false } + { + name: "One", + address: "a".repeat(64), + displayAddress: "base58-one", + balance: "10", + isPublic: true + }, + { + name: "Two", + address: "b".repeat(64), + displayAddress: "base58-two", + balance: "20", + isPublic: false + } ]) mouseClick(findChild(fixture.control, "walletAccountButton")) const accountsButton = findChild(fixture.control, "walletAccountsButton") @@ -240,7 +435,151 @@ Item { const secondAccount = accountList.itemAtIndex(1) secondAccount.clicked() tryCompare(fixture.control, "selectedIndex", 1) + compare(fixture.backend.primaryAccountAddress, "b".repeat(64)) compare(fixture.control.selectedAddress, "b".repeat(64)) + compare(fixture.control.selectedDisplayAddress, "base58-two") + } + + function test_flatWalletActionsUseReadableForeground() { + const fixture = createControl({ + isWalletOpen: true, + assets: [{ + name: "Available", + balance: "0", + definitionId: "c".repeat(64), + displayDefinitionId: "base58-available", + status: "ready", + section: "available" + }] + }, [ + { + name: "One", + address: "a".repeat(64), + balance: "10", + isPublic: true, + isPrimary: true + }, + { + name: "Two", + address: "b".repeat(64), + balance: "20", + isPublic: true + } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + + const available = findChild(fixture.control, "walletAvailableAssetsButton") + tryVerify(function() { return available && available.visible }) + compare(available.flat, true) + compare(available.palette.windowText, "#d4d4d8") + compare(available.contentItem.color, "#d4d4d8") + mouseClick(available) + tryCompare(fixture.control, "availableExpanded", true) + + mouseClick(findChild(fixture.control, "walletAccountsButton")) + const advanced = findChild(fixture.control, "walletAdvancedAccountsButton") + tryVerify(function() { return advanced && advanced.visible }) + compare(advanced.flat, true) + compare(advanced.palette.windowText, "#d4d4d8") + compare(advanced.contentItem.color, "#d4d4d8") + + const accountList = findChild(fixture.control, "walletAccountList") + tryVerify(function() { return accountList.itemAtIndex(1) !== null }) + const secondAccount = accountList.itemAtIndex(1) + const rename = findChild(secondAccount, "walletRenameButton") + const makePrimary = findChild(secondAccount, "walletMakePrimaryButton") + verify(rename && makePrimary, "Account action buttons exist") + for (const action of [rename, makePrimary]) { + compare(action.flat, true) + compare(action.palette.windowText, "#d4d4d8") + compare(action.contentItem.color, "#d4d4d8") + } + } + + function test_accountNavigationKeepsOverviewInsidePopup() { + const assets = [] + for (let index = 0; index < 10; ++index) { + assets.push({ + name: "Token " + index, + balance: "100", + definitionId: "c".repeat(64), + displayDefinitionId: "base58-token-" + index, + status: "ready", + section: "assets" + }) + } + const fixture = createControl({ isWalletOpen: true, assets: assets }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const stack = findChild(fixture.control, "walletStack") + verify(stack, "Wallet stack exists") + verify(stack.clip, "Wallet pages are clipped to the popup") + mouseClick(findChild(fixture.control, "walletAccountsButton")) + tryCompare(stack, "busy", false) + compare(stack.depth, 2) + + mouseClick(findChild(fixture.control, "walletAccountsBackButton")) + tryCompare(stack, "busy", false) + compare(stack.depth, 1) + compare(stack.currentItem.x, 0) + const overviewContent = findChild(fixture.control, "walletOverviewContent") + verify(overviewContent, "Wallet overview content exists") + compare(overviewContent.mapToItem(stack, 0, 0).x, 0) + } + + function test_programRecordCannotBecomePrimary() { + const userAddress = "a".repeat(64) + const programAddress = "c".repeat(64) + const fixture = createControl({ + isWalletOpen: true, + primaryAccountAddress: userAddress, + primaryAccountName: "Trading" + }, [ + { + name: "Trading", + address: userAddress, + balance: "10", + isPublic: true, + kind: "user", + isPrimary: true + }, + { + name: "Token definition", + address: programAddress, + balance: "0", + isPublic: true, + kind: "token_definition", + section: "advanced", + programName: "Token", + accountType: "TokenDefinition", + canBePrimary: false + } + ]) + compare(fixture.control.selectedAddress, userAddress) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + mouseClick(findChild(fixture.control, "walletAdvancedAccountsButton")) + const list = findChild(fixture.control, "walletAccountList") + tryVerify(function() { return list.itemAtIndex(1) !== null }) + list.itemAtIndex(1).clicked() + compare(fixture.backend.primaryAccountCalls, 0) + compare(fixture.control.selectedAddress, userAddress) + } + + function test_onlyProgramRecordsLeavesPrimaryEmpty() { + const fixture = createControl({ isWalletOpen: true }, [{ + name: "Token definition", + address: "c".repeat(64), + balance: "0", + isPublic: true, + kind: "token_definition", + section: "advanced", + canBePrimary: false + }]) + compare(fixture.control.selectedIndex, -1) + compare(fixture.control.selectedAddress, "") + compare(fixture.control.primaryName, "") } function test_createsAccount() { @@ -262,6 +601,47 @@ Item { tryCompare(dialog, "opened", false) } + function test_createsPrivateAccount() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + const addButton = findChild(fixture.control, "walletAddAccountButton") + tryVerify(function() { return addButton.visible }) + addButton.clicked() + const dialog = findChild(fixture.control, "createAccountDialog") + tryCompare(dialog, "opened", true) + mouseClick(findChild(dialog, "privateAccountSwitch")) + findChild(dialog, "createAccountButton").clicked() + compare(fixture.backend.privateAccountCalls, 1) + tryCompare(dialog, "opened", false) + } + + function test_warnsWhenCreatedAccountCannotRefresh() { + const fixture = createControl({ + isWalletOpen: true, + walletSyncStatus: "ready", + accountRefreshFails: true + }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + const addButton = findChild(fixture.control, "walletAddAccountButton") + tryVerify(function() { return addButton.visible }) + addButton.clicked() + const dialog = findChild(fixture.control, "createAccountDialog") + tryCompare(dialog, "opened", true) + findChild(dialog, "createAccountButton").clicked() + tryCompare(dialog, "opened", false) + + const message = findChild(fixture.control, "walletMessageDialog") + tryCompare(message, "opened", true) + compare(message.message, + "Account was created, but could not be refreshed. Reconnect the wallet to refresh it.") + } + function test_compactLayoutHasStableWidth() { const fixture = createControl({ isWalletOpen: false }, []) fixture.control.viewportWidth = 480 @@ -300,12 +680,12 @@ Item { const model = createTemporaryObject(modelComponent, root) verify(backend && model, "Wallet fixture exists") for (let index = 0; index < 10; ++index) { - model.append({ + model.append(accountData({ name: "Account " + index, address: String(index).repeat(64), balance: String(index), isPublic: true - }) + })) } const window = createTemporaryObject(compactWindowComponent, root) diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h index d5dea0ad..e86720ae 100644 --- a/apps/shared/wallet/tests/support/FakeWalletProvider.h +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "WalletProvider.h" class FakeWalletProvider final : public WalletProvider { @@ -9,6 +11,7 @@ class FakeWalletProvider final : public WalletProvider { WalletSnapshot snapshotResult; WalletAccountCreation createAccountResult; WalletAccountRead readResult; + QVector readResults; WalletSubmission submissionResult; int connectCalls = 0; @@ -17,12 +20,24 @@ class FakeWalletProvider final : public WalletProvider { int clearCalls = 0; int createAccountCalls = 0; mutable int readCalls = 0; + int publicAccountReadCalls = 0; int submitCalls = 0; int disconnectCalls = 0; bool lastForceRefresh = false; bool lastAccountWasPublic = false; WalletPaths lastPaths; WalletTransaction lastTransaction; + QStringList lastPublicAccountIds; + bool deferPublicAccountReads = false; + + struct PendingPublicAccountRead { + QStringList accountIds; + AccountReadsCallback callback; + }; + QVector pendingPublicAccountReads; + bool deferAsync = false; + SessionCallback pendingConnectCallback; + SnapshotCallback pendingSnapshotCallback; WalletSession connect(const WalletPaths& paths) override { @@ -31,6 +46,16 @@ class FakeWalletProvider final : public WalletProvider { return connectResult; } + void connectAsync(const WalletPaths& paths, SessionCallback callback) override + { + ++connectCalls; + lastPaths = paths; + if (deferAsync) + pendingConnectCallback = std::move(callback); + else + callback(connectResult); + } + WalletCreation createWallet(const WalletPaths& paths, const QString&) override { @@ -46,6 +71,16 @@ class FakeWalletProvider final : public WalletProvider { return snapshotResult; } + void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override + { + ++snapshotCalls; + lastForceRefresh = forceRefresh; + if (deferAsync) + pendingSnapshotCallback = std::move(callback); + else + callback(snapshotResult); + } + void clearSnapshot() override { ++clearCalls; } WalletAccountCreation createAccount(bool isPublic) override @@ -55,6 +90,11 @@ class FakeWalletProvider final : public WalletProvider { return createAccountResult; } + void createAccountAsync(bool isPublic, AccountCreationCallback callback) override + { + callback(createAccount(isPublic)); + } + WalletAccountRead readPublicAccount(const QString& accountId) const override { ++readCalls; @@ -63,6 +103,26 @@ class FakeWalletProvider final : public WalletProvider { return result; } + void readPublicAccountsAsync(const QStringList& accountIds, + AccountReadsCallback callback) override + { + ++publicAccountReadCalls; + lastPublicAccountIds = accountIds; + if (deferPublicAccountReads) { + pendingPublicAccountReads.append({ accountIds, std::move(callback) }); + return; + } + callback(accountReads(accountIds)); + } + + void completePendingPublicAccountReads() + { + QVector pending; + pending.swap(pendingPublicAccountReads); + for (PendingPublicAccountRead& read : pending) + read.callback(accountReads(read.accountIds)); + } + WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) override { @@ -71,5 +131,40 @@ class FakeWalletProvider final : public WalletProvider { return submissionResult; } + void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) override + { + callback(submitPublicTransaction(transaction)); + } + void disconnect() override { ++disconnectCalls; } + + void finishConnect() + { + SessionCallback callback = std::move(pendingConnectCallback); + if (callback) + callback(connectResult); + } + + void finishSnapshot() + { + SnapshotCallback callback = std::move(pendingSnapshotCallback); + if (callback) + callback(snapshotResult); + } + +private: + QVector accountReads(const QStringList& accountIds) const + { + QVector results = readResults; + if (results.isEmpty()) { + results.reserve(accountIds.size()); + for (const QString& accountId : accountIds) { + WalletAccountRead result = readResult; + result.accountId = accountId; + results.append(std::move(result)); + } + } + return results; + } }; diff --git a/flake.nix b/flake.nix index 1fa131a8..eb574d0c 100644 --- a/flake.nix +++ b/flake.nix @@ -101,10 +101,32 @@ ''; } ); + + walletDecoderArgs = commonArgs // { + pname = "wallet-idl-decoder"; + cargoExtraArgs = "-p wallet-idl-decoder"; + }; + walletDecoder = craneLib.buildPackage ( + walletDecoderArgs + // { + cargoArtifacts = craneLib.buildDepsOnly walletDecoderArgs; + postInstall = + '' + mkdir -p $out/include + cp tools/wallet-idl-decoder/include/wallet_idl_decoder.h $out/include/ + '' + + pkgs.lib.optionalString pkgs.stdenv.isDarwin '' + if [ -f $out/lib/libwallet_idl_decoder.dylib ]; then + install_name_tool -id "$out/lib/libwallet_idl_decoder.dylib" $out/lib/libwallet_idl_decoder.dylib + fi + ''; + } + ); in { packages.default = ammClient; packages.amm_client = ammClient; + packages.wallet_idl_decoder = walletDecoder; } ); @@ -120,6 +142,7 @@ flakeInputs = inputs; externalLibInputs = { amm_client = { input = self; packages.default = "amm_client"; }; + wallet_idl_decoder = { input = self; packages.default = "wallet_idl_decoder"; }; }; # The AMM UI links the shared C++ wallet access lib and bundles the # Logos.Wallet QML module (apps/shared/wallet). apps/amm/flake.nix wires @@ -128,6 +151,7 @@ # built QML module the same way. Keep in sync with apps/amm/flake.nix. preConfigure = '' cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${./apps/shared/wallet}") + cmakeFlagsArray+=("-DLEZ_IDL_ARTIFACTS_DIR=${./artifacts}") ''; postInstall = '' test -f ${./apps/amm/qml}/Logos/Wallet/qmldir @@ -163,10 +187,11 @@ let pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; }; ammClient = crateOutputs.packages.${system}.amm_client; + walletDecoder = crateOutputs.packages.${system}.wallet_idl_decoder; in app // { program = "${pkgs.writeShellScript "run-amm-ui" '' - export DYLD_FALLBACK_LIBRARY_PATH="${ammClient}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" + export DYLD_FALLBACK_LIBRARY_PATH="${ammClient}/lib:${walletDecoder}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" exec ${app.program} "$@" ''}"; }; diff --git a/tools/wallet-idl-decoder/Cargo.toml b/tools/wallet-idl-decoder/Cargo.toml new file mode 100644 index 00000000..e407f8b1 --- /dev/null +++ b/tools/wallet-idl-decoder/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "wallet-idl-decoder" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[lib] +name = "wallet_idl_decoder" +crate-type = ["cdylib", "rlib"] + +[dependencies] +base58 = "0.2" +hex = "0.4" +serde = { workspace = true } +serde_json = { workspace = true } +spel-framework-core = { git = "https://github.com/logos-co/spel.git", tag = "v0.6.0" } diff --git a/tools/wallet-idl-decoder/include/wallet_idl_decoder.h b/tools/wallet-idl-decoder/include/wallet_idl_decoder.h new file mode 100644 index 00000000..fd724f37 --- /dev/null +++ b/tools/wallet-idl-decoder/include/wallet_idl_decoder.h @@ -0,0 +1,15 @@ +#ifndef WALLET_IDL_DECODER_H +#define WALLET_IDL_DECODER_H + +#ifdef __cplusplus +extern "C" { +#endif + +char *wallet_idl_decode_accounts(const char *request_json); +void wallet_idl_decoder_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/tools/wallet-idl-decoder/src/lib.rs b/tools/wallet-idl-decoder/src/lib.rs new file mode 100644 index 00000000..db7a2010 --- /dev/null +++ b/tools/wallet-idl-decoder/src/lib.rs @@ -0,0 +1,278 @@ +use std::{ + collections::BTreeMap, + ffi::{CStr, CString}, + os::raw::c_char, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use base58::FromBase58; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use spel_framework_core::{decode::decode_account_data_try_all, idl::SpelIdl}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DecodeRequest { + idl: SpelIdl, + accounts: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AccountInput { + id: String, + data_hex: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DecodeResponse { + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'static str>, + accounts: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AccountOutput { + id: String, + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + type_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + account_ids: BTreeMap, +} + +fn decode_request(request: DecodeRequest) -> DecodeResponse { + let accounts = request + .accounts + .into_iter() + .map(|account| decode_account(account, &request.idl)) + .collect(); + DecodeResponse { + status: "ok", + error: None, + accounts, + } +} + +fn decode_account(account: AccountInput, idl: &SpelIdl) -> AccountOutput { + let Ok(data) = hex::decode(&account.data_hex) else { + return AccountOutput { + id: account.id, + status: "invalid_data", + type_name: None, + value: None, + account_ids: BTreeMap::new(), + }; + }; + let Some((type_name, value)) = decode_account_data_try_all(&data, idl) else { + return AccountOutput { + id: account.id, + status: "unknown_type", + type_name: None, + value: None, + account_ids: BTreeMap::new(), + }; + }; + let mut account_ids = BTreeMap::new(); + collect_account_ids(&value, &mut account_ids); + AccountOutput { + id: account.id, + status: "decoded", + type_name: Some(type_name), + value: Some(value), + account_ids, + } +} + +fn collect_account_ids(value: &Value, output: &mut BTreeMap) { + match value { + Value::String(encoded) => { + let Some(base58) = encoded.strip_prefix("Public/") else { + return; + }; + if let Ok(bytes) = base58.from_base58() { + if bytes.len() == 32 { + output.insert(encoded.clone(), hex::encode(bytes)); + } + } + } + Value::Array(values) => { + for nested in values { + collect_account_ids(nested, output); + } + } + Value::Object(values) => { + for nested in values.values() { + collect_account_ids(nested, output); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn error_response(error: &'static str) -> DecodeResponse { + DecodeResponse { + status: "error", + error: Some(error), + accounts: Vec::new(), + } +} + +fn response_pointer(response: &DecodeResponse) -> *mut c_char { + let json = serde_json::to_string(response).unwrap_or_else(|_| { + String::from(r#"{"status":"error","error":"serialization_failed","accounts":[]}"#) + }); + CString::new(json).map_or(std::ptr::null_mut(), CString::into_raw) +} + +#[expect( + unsafe_code, + reason = "C ABI input requires reading a caller-owned C string" +)] +fn decode_pointer(request_json: *const c_char) -> DecodeResponse { + if request_json.is_null() { + return error_response("null_request"); + } + let bytes = unsafe { + // SAFETY: Caller owns a non-null NUL-terminated C string for this call. + CStr::from_ptr(request_json) + }; + let Ok(json) = bytes.to_str() else { + return error_response("invalid_utf8"); + }; + match serde_json::from_str::(json) { + Ok(request) => decode_request(request), + Err(_) => error_response("invalid_request"), + } +} + +/// Decodes a JSON batch request using its embedded SPEL IDL. +/// +/// Returns a library-owned JSON C string. Release it with +/// [`wallet_idl_decoder_free`]. +#[no_mangle] +#[expect(unsafe_code, reason = "C ABI requires a stable exported symbol")] +pub extern "C" fn wallet_idl_decode_accounts(request_json: *const c_char) -> *mut c_char { + let response = catch_unwind(AssertUnwindSafe(|| decode_pointer(request_json))) + .unwrap_or_else(|_| error_response("panic")); + response_pointer(&response) +} + +/// Frees a response allocated by [`wallet_idl_decode_accounts`]. +/// +/// # Safety +/// +/// `value` must be null or a pointer returned by this library that has not +/// already been freed. +#[no_mangle] +#[expect(unsafe_code, reason = "C ABI deallocator reconstructs its CString")] +pub unsafe extern "C" fn wallet_idl_decoder_free(value: *mut c_char) { + if !value.is_null() { + unsafe { + // SAFETY: Pointer must come from CString::into_raw in this library and be freed once. + drop(CString::from_raw(value)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn token_idl() -> SpelIdl { + match serde_json::from_str(include_str!("../../../artifacts/token-idl.json")) { + Ok(idl) => idl, + Err(error) => panic!("committed token IDL should parse: {error}"), + } + } + + #[test] + fn decodes_fungible_definition() { + let request = DecodeRequest { + idl: token_idl(), + accounts: vec![AccountInput { + id: "definition".to_owned(), + data_hex: concat!( + "00", // Fungible variant + "04000000", + "54455354", // TEST + "0a000000000000000000000000000000", // supply 10 + "00", // metadata_id None + "00" // authority None + ) + .to_owned(), + }], + }; + let response = decode_request(request); + let Some(account) = response.accounts.first() else { + panic!("decoder should return one account"); + }; + assert_eq!(account.status, "decoded"); + assert_eq!(account.type_name.as_deref(), Some("TokenDefinition")); + assert_eq!( + account + .value + .as_ref() + .and_then(|value| value.get("Fungible")) + .and_then(|value| value.get("name")) + .and_then(Value::as_str), + Some("TEST") + ); + } + + #[test] + fn maps_decoded_public_ids_to_hex() { + let request = DecodeRequest { + idl: token_idl(), + accounts: vec![AccountInput { + id: "holding".to_owned(), + data_hex: format!("00{}19000000000000000000000000000000", "01".repeat(32)), + }], + }; + let response = decode_request(request); + let Some(account) = response.accounts.first() else { + panic!("decoder should return one account"); + }; + let expected = "01".repeat(32); + assert_eq!(account.status, "decoded"); + assert_eq!(account.type_name.as_deref(), Some("TokenHolding")); + assert_eq!( + account.account_ids.values().next().map(String::as_str), + Some(expected.as_str()) + ); + } + + #[test] + fn rejects_invalid_hex_per_account() { + let response = decode_request(DecodeRequest { + idl: token_idl(), + accounts: vec![AccountInput { + id: "broken".to_owned(), + data_hex: "xyz".to_owned(), + }], + }); + assert_eq!(response.status, "ok"); + assert_eq!( + response.accounts.first().map(|account| account.status), + Some("invalid_data") + ); + } + + #[test] + #[expect(unsafe_code, reason = "test verifies the exported C allocator pair")] + fn ffi_allocates_json_and_accepts_its_pointer_on_free() { + let response = wallet_idl_decode_accounts(std::ptr::null()); + assert!(!response.is_null()); + let json = match unsafe { CStr::from_ptr(response) }.to_str() { + Ok(json) => json, + Err(error) => panic!("response should be UTF-8 JSON: {error}"), + }; + assert!(json.contains("null_request")); + unsafe { wallet_idl_decoder_free(response) }; + } +} From 558cef4c8768ed2448a0fee75f04a6057ebd4ea8 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Sat, 18 Jul 2026 19:43:46 -0300 Subject: [PATCH 2/2] fix(wallet): harden shared wallet integration --- apps/amm/CMakeLists.txt | 82 +-- apps/amm/README.md | 17 + apps/amm/config/networks.json | 18 - apps/amm/src/ActiveNetwork.cpp | 139 ----- apps/amm/src/ActiveNetwork.h | 32 +- apps/amm/src/AmmUiBackend.cpp | 552 +++++------------- apps/amm/src/AmmUiBackend.h | 54 +- apps/amm/tests/cpp/ActiveNetworkTest.cpp | 47 -- .../cpp/AmmUiBackendDefinitionCacheTest.cpp | 242 -------- apps/shared/wallet/CMakeLists.txt | 146 ++++- apps/shared/wallet/config/networks.json | 5 + apps/shared/wallet/qml/WalletControl.qml | 63 +- .../wallet/qml/internal/AccountDelegate.qml | 42 +- .../wallet/src/SequencerIdentityProbe.cpp | 285 +++++++++ .../wallet/src/SequencerIdentityProbe.h | 91 +++ .../wallet/src/SequencerNetworkContext.cpp | 133 +++++ .../wallet/src/SequencerNetworkContext.h | 60 ++ .../wallet/src/SequencerNetworkSettings.cpp | 60 ++ .../wallet/src/SequencerNetworkSettings.h | 27 + .../wallet}/src/TokenDefinitionCache.cpp | 0 .../wallet}/src/TokenDefinitionCache.h | 0 apps/shared/wallet/src/WalletAccountModel.cpp | 76 +++ apps/shared/wallet/src/WalletAccountModel.h | 5 + apps/shared/wallet/src/WalletController.cpp | 20 + apps/shared/wallet/src/WalletController.h | 1 + .../wallet}/src/WalletIdlDecoder.cpp | 5 +- .../wallet}/src/WalletIdlDecoder.h | 0 .../wallet/src/WalletPortfolioService.cpp | 536 +++++++++++++++++ .../wallet/src/WalletPortfolioService.h | 81 +++ .../tests/cpp/LogosWalletProviderTest.cpp | 72 +++ .../tests/cpp/SequencerIdentityProbeTest.cpp | 329 +++++++++++ .../tests/cpp/SequencerNetworkContextTest.cpp | 96 +++ .../cpp/SequencerNetworkSettingsTest.cpp | 66 +++ .../tests/cpp/TokenDefinitionCacheTest.cpp | 0 .../tests/cpp/WalletIdlDecoderLinkTest.cpp | 22 + .../tests/cpp/WalletPortfolioServiceTest.cpp | 259 ++++++++ .../wallet/tests/qml/tst_WalletControl.qml | 159 +++++ 37 files changed, 2834 insertions(+), 988 deletions(-) delete mode 100644 apps/amm/config/networks.json delete mode 100644 apps/amm/src/ActiveNetwork.cpp delete mode 100644 apps/amm/tests/cpp/ActiveNetworkTest.cpp delete mode 100644 apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp create mode 100644 apps/shared/wallet/config/networks.json create mode 100644 apps/shared/wallet/src/SequencerIdentityProbe.cpp create mode 100644 apps/shared/wallet/src/SequencerIdentityProbe.h create mode 100644 apps/shared/wallet/src/SequencerNetworkContext.cpp create mode 100644 apps/shared/wallet/src/SequencerNetworkContext.h create mode 100644 apps/shared/wallet/src/SequencerNetworkSettings.cpp create mode 100644 apps/shared/wallet/src/SequencerNetworkSettings.h rename apps/{amm => shared/wallet}/src/TokenDefinitionCache.cpp (100%) rename apps/{amm => shared/wallet}/src/TokenDefinitionCache.h (100%) rename apps/{amm => shared/wallet}/src/WalletIdlDecoder.cpp (97%) rename apps/{amm => shared/wallet}/src/WalletIdlDecoder.h (100%) create mode 100644 apps/shared/wallet/src/WalletPortfolioService.cpp create mode 100644 apps/shared/wallet/src/WalletPortfolioService.h create mode 100644 apps/shared/wallet/tests/cpp/SequencerIdentityProbeTest.cpp create mode 100644 apps/shared/wallet/tests/cpp/SequencerNetworkContextTest.cpp create mode 100644 apps/shared/wallet/tests/cpp/SequencerNetworkSettingsTest.cpp rename apps/{amm => shared/wallet}/tests/cpp/TokenDefinitionCacheTest.cpp (100%) create mode 100644 apps/shared/wallet/tests/cpp/WalletIdlDecoderLinkTest.cpp create mode 100644 apps/shared/wallet/tests/cpp/WalletPortfolioServiceTest.cpp diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index c9b01506..c689f797 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(AmmUiPlugin LANGUAGES CXX) -find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2) +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2) qt_standard_project_setup(REQUIRES 6.8) find_package(PkgConfig REQUIRED) @@ -23,6 +23,30 @@ set(LOGOS_WALLET_GENERATED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/generated_code" CACHE PATH "Path to generated Logos SDK sources" ) +# LogosModule.cmake discovers external libraries only while it creates the AMM +# plugin, after this shared static target has already been configured. When the +# builder has staged the decoder locally, expose it to direct shared-wallet +# consumers too; the plugin's EXTERNAL_LIBS entry below remains the canonical +# packaging/runtime dependency. +if(NOT LOGOS_WALLET_IDL_DECODER_LIBRARY) + set(amm_wallet_decoder_search_path "${CMAKE_CURRENT_SOURCE_DIR}/lib") + if(DEFINED ENV{LOGOS_EXT_ROOT_WALLET_IDL_DECODER}) + set(amm_wallet_decoder_search_path + "$ENV{LOGOS_EXT_ROOT_WALLET_IDL_DECODER}/lib") + endif() + find_library(AMM_WALLET_IDL_DECODER_LIBRARY + NAMES wallet_idl_decoder + PATHS "${amm_wallet_decoder_search_path}" + NO_DEFAULT_PATH + ) + if(AMM_WALLET_IDL_DECODER_LIBRARY) + set(LOGOS_WALLET_IDL_DECODER_LIBRARY + "${AMM_WALLET_IDL_DECODER_LIBRARY}" + CACHE FILEPATH + "wallet_idl_decoder library required by logos_wallet_access" + ) + endif() +endif() add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet") # ui_qml module with a hand-written C++ backend (QtRO .rep view contract + @@ -37,22 +61,16 @@ logos_module( src/AmmUiBackend.h src/AmmUiBackend.cpp src/ActiveNetwork.h - src/ActiveNetwork.cpp src/AmmClient.h src/AmmClient.cpp src/NewPositionRuntime.h src/NewPositionRuntime.cpp src/SwapRuntime.h src/SwapRuntime.cpp - src/TokenDefinitionCache.h - src/TokenDefinitionCache.cpp - src/WalletIdlDecoder.h - src/WalletIdlDecoder.cpp FIND_PACKAGES Qt6Gui LINK_LIBRARIES Qt6::Gui - Qt6::Network PkgConfig::BASE58 LINK_TARGETS logos_wallet_access @@ -85,7 +103,6 @@ set_source_files_properties( qt_add_resources(amm_ui_module_plugin amm_ui_wallet_data PREFIX "/amm" FILES - config/networks.json "${AMM_TOKEN_IDL}" "${AMM_IDL}" ) @@ -105,53 +122,4 @@ if(BUILD_TESTING) ) add_test(NAME amm_new_position_runtime COMMAND amm_new_position_runtime_test) - add_executable(amm_active_network_test - tests/cpp/ActiveNetworkTest.cpp - src/ActiveNetwork.cpp - ) - set_target_properties(amm_active_network_test PROPERTIES AUTOMOC ON) - target_include_directories(amm_active_network_test PRIVATE src) - target_link_libraries(amm_active_network_test PRIVATE Qt6::Core Qt6::Test) - add_test(NAME amm_active_network COMMAND amm_active_network_test) - - add_executable(amm_token_definition_cache_test - tests/cpp/TokenDefinitionCacheTest.cpp - src/TokenDefinitionCache.h - src/TokenDefinitionCache.cpp - ) - set_target_properties(amm_token_definition_cache_test PROPERTIES AUTOMOC ON) - target_compile_features(amm_token_definition_cache_test PRIVATE cxx_std_17) - target_include_directories(amm_token_definition_cache_test PRIVATE - src - "${LOGOS_WALLET_SOURCE_DIR}/src" - "${LOGOS_WALLET_SOURCE_DIR}/tests/support" - ) - target_link_libraries(amm_token_definition_cache_test PRIVATE Qt6::Core Qt6::Test) - add_test(NAME amm_token_definition_cache COMMAND amm_token_definition_cache_test) - - add_executable(amm_backend_definition_cache_test - tests/cpp/AmmUiBackendDefinitionCacheTest.cpp - ) - add_dependencies(amm_backend_definition_cache_test amm_ui_module_plugin) - set_target_properties(amm_backend_definition_cache_test PROPERTIES AUTOMOC ON) - target_compile_features(amm_backend_definition_cache_test PRIVATE cxx_std_17) - target_include_directories(amm_backend_definition_cache_test PRIVATE - src - "${CMAKE_CURRENT_BINARY_DIR}" - "${LOGOS_WALLET_SOURCE_DIR}/src" - "${LOGOS_WALLET_SOURCE_DIR}/tests/support" - ) - target_link_libraries(amm_backend_definition_cache_test PRIVATE - Qt6::Core - Qt6::Network - Qt6::RemoteObjects - Qt6::Test - amm_ui_module_plugin - ) - if(UNIX AND NOT APPLE) - target_link_options(amm_backend_definition_cache_test PRIVATE - "-Wl,--allow-shlib-undefined" - ) - endif() - add_test(NAME amm_backend_definition_cache COMMAND amm_backend_definition_cache_test) endif() diff --git a/apps/amm/README.md b/apps/amm/README.md index b33841a0..70893a5e 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -151,6 +151,23 @@ nix run .#amm-ui Without `AMM_PROGRAM_BIN` the Swap and Liquidity views stay disabled; without `TOKENS_CONFIG` the token picker is empty. Each is detailed below. +### Network identity + +The shared wallet verifies the sequencer before it enables network-dependent +portfolio data or AMM quotes. Testnet uses the bundled checkpoint identity. For +devnet, provide the channel identity emitted by the local sequencer: + +```bash +LOGOS_WALLET_NETWORK=devnet \ +LOGOS_WALLET_DEVNET_FILE=/abs/path/to/devnet.json \ +nix run .#amm-ui +``` + +`devnet.json` must contain a 64-character lowercase-hex `channelId`. The +legacy `AMM_UI_NETWORK` and `AMM_UI_DEVNET_FILE` names remain accepted. AMM +deployment and token selection stay app-specific through `AMM_PROGRAM_BIN` and +`TOKENS_CONFIG`. + ### AMM program binary (required for swaps and liquidity) To execute a swap, the app must submit a transaction against the **exact AMM diff --git a/apps/amm/config/networks.json b/apps/amm/config/networks.json deleted file mode 100644 index 75e03eba..00000000 --- a/apps/amm/config/networks.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "testnet": { - "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a", - "ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07", - "tokenDefinitionIds": [ - "7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6", - "48c81cf032e601ca367fc9816b957dbf5c0e4c11cf7008e8f4581ec1a67aab42", - "159caef810ea545951b3bd913efe625ee45008c80865c330e72a72ed48b61649", - "75f33110b185717209e3955f228d4a4448801d0ce8ba438a4a268050eeff3f44", - "fbd107ca4bb66bc58f59ac2d32a759be3ee0fb453f8fecd1991c11837d9660c7", - "5547fcb72644d95a385d313b887a96be41ff263bce6150b49fd87276839822bf", - "fa43e74a97d79c5f907ff3edabda5ad89bfbd3b0922572e675d4ad3c7b6029c7", - "4f3231d8a01e1d79f163bc27fce0c860a4a2f6890280e9d135eafbde0d68ed79", - "fa32f354408857006f8ea396b0419823bd04436eadb2d273d2618a46b4793ed8", - "00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb" - ] - } -} diff --git a/apps/amm/src/ActiveNetwork.cpp b/apps/amm/src/ActiveNetwork.cpp deleted file mode 100644 index 9bb1e275..00000000 --- a/apps/amm/src/ActiveNetwork.cpp +++ /dev/null @@ -1,139 +0,0 @@ -#include "ActiveNetwork.h" - -#include -#include -#include -#include - -namespace { -const char NETWORK_ENV[] = "AMM_UI_NETWORK"; -const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE"; - -bool isLowerHex(const QString& value, int size) -{ - if (value.size() != size) - return false; - for (const QChar character : value) { - const bool digit = character >= QLatin1Char('0') - && character <= QLatin1Char('9'); - if (!digit && (character < QLatin1Char('a') || character > QLatin1Char('f'))) - return false; - } - return true; -} -} - -bool ActiveNetwork::load() -{ - m_network = {}; - m_network.status = QStringLiteral("config_missing"); - m_expectedIdentity.clear(); - const QByteArray selected = qgetenv(NETWORK_ENV); - m_network.id = selected.isEmpty() - ? QStringLiteral("testnet") - : QString::fromLocal8Bit(selected).trimmed(); - - QJsonObject entry; - if (isDevnet()) { - const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV)); - QFile file(path); - if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) - return false; - const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); - if (!document.isObject()) - return false; - entry = document.object(); - m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString(); - } else { - QFile file(QStringLiteral(":/amm/config/networks.json")); - if (!file.open(QIODevice::ReadOnly)) - return false; - const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); - if (!document.isObject()) - return false; - entry = document.object().value(m_network.id).toObject(); - m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString(); - } - - m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString(); - if (!isValidIdentity(m_expectedIdentity) - || !isLowerHex(m_network.ammProgramId, 64)) { - return false; - } - for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) { - const QString id = value.toString(); - if (!isLowerHex(id, 64)) { - m_network.tokenIds.clear(); - return false; - } - m_network.tokenIds.append(id); - } - if (m_network.tokenIds.isEmpty()) - return false; - m_network.status = QStringLiteral("network_unknown"); - return true; -} - -bool ActiveNetwork::isConfigured() const -{ - return m_network.status != QStringLiteral("config_missing"); -} - -bool ActiveNetwork::isDevnet() const -{ - return m_network.id == QStringLiteral("devnet"); -} - -bool ActiveNetwork::needsIdentityProbe() const -{ - return m_network.status == QStringLiteral("loading") - || m_network.status == QStringLiteral("network_unknown"); -} - -void ActiveNetwork::sequencerChanged(bool available) -{ - if (isConfigured()) - clearIdentity(available ? QStringLiteral("loading") - : QStringLiteral("network_unknown")); -} - -void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable) -{ - if (!isConfigured()) - return; - if (!reachable) - clearIdentity(QStringLiteral("network_unknown")); - else if (!wasReachable) - clearIdentity(QStringLiteral("loading")); -} - -void ActiveNetwork::beginIdentityProbe() -{ - if (isConfigured()) - clearIdentity(QStringLiteral("loading")); -} - -void ActiveNetwork::finishIdentityProbe(const QString& identity) -{ - if (identity.isEmpty()) - clearIdentity(QStringLiteral("network_unknown")); - else if (identity != m_expectedIdentity) - clearIdentity(QStringLiteral("network_mismatch")); - else { - m_network.status = QStringLiteral("ready"); - m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:") - : QStringLiteral("block10:")) - + identity; - } -} - -bool ActiveNetwork::isValidIdentity(const QString& value) -{ - return isLowerHex(value, 64); -} - -void ActiveNetwork::clearIdentity(const QString& status) -{ - m_network.status = status; - m_network.fingerprint.clear(); -} diff --git a/apps/amm/src/ActiveNetwork.h b/apps/amm/src/ActiveNetwork.h index 5802320a..6da475a6 100644 --- a/apps/amm/src/ActiveNetwork.h +++ b/apps/amm/src/ActiveNetwork.h @@ -3,11 +3,9 @@ #include #include -// Network context handed to the new-position flow. The AMM deployment identity -// (ammProgramId, from $AMM_PROGRAM_BIN) and the configured token set (tokenIds, -// from $TOKENS_CONFIG) are the same sources the Swap view uses; there is no -// separate network config file or channel-identity probe. `fingerprint` binds a -// quote to the deployment so a quote can't be replayed against a different one. +// AMM-specific deployment context handed to the new-position flow. Shared +// wallet code verifies the sequencer identity; this type adds the AMM program +// and configured token definitions required only by the AMM runtime. struct ActiveNetworkSnapshot { QString id; QString status; @@ -15,27 +13,3 @@ struct ActiveNetworkSnapshot { QString ammProgramId; QStringList tokenIds; }; - -class ActiveNetwork final { -public: - bool load(); - - const QString& status() const { return m_network.status; } - bool isConfigured() const; - bool isDevnet() const; - bool needsIdentityProbe() const; - ActiveNetworkSnapshot snapshot() const { return m_network; } - - void sequencerChanged(bool available); - void reachabilityChanged(bool reachable, bool wasReachable); - void beginIdentityProbe(); - void finishIdentityProbe(const QString& identity); - - static bool isValidIdentity(const QString& value); - -private: - void clearIdentity(const QString& status); - - ActiveNetworkSnapshot m_network; - QString m_expectedIdentity; -}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 0c8f6f45..8895c6be 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,25 +1,15 @@ #include "AmmUiBackend.h" -#include - #include -#include +#include -#include -#include #include -#include #include -#include -#include #include #include #include #include -#include -#include -#include -#include +#include #include #include @@ -27,9 +17,10 @@ #include "LogosWalletProvider.h" #include "NewPositionRuntime.h" #include "SwapRuntime.h" -#include "WalletAccountId.h" +#include "SequencerIdentityProbe.h" +#include "SequencerNetworkSettings.h" #include "WalletController.h" -#include "WalletIdlDecoder.h" +#include "WalletPortfolioService.h" #include "logos_api.h" #include "logos_sdk.h" @@ -48,93 +39,28 @@ namespace { // (see apps/amm/README.md). Config-driven so the Swap view's token picker // doesn't need a hardcoded/dummy token list. const char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; + + // The shared wallet owns network verification. Keep AMM names as a + // backwards-compatible fallback while allowing other wallet consumers to + // use the common environment names. + const char WALLET_NETWORK_ENV[] = "LOGOS_WALLET_NETWORK"; + const char LEGACY_NETWORK_ENV[] = "AMM_UI_NETWORK"; + const char WALLET_DEVNET_FILE_ENV[] = "LOGOS_WALLET_DEVNET_FILE"; + const char LEGACY_DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE"; + } namespace { -constexpr int CHECKPOINT_BLOCK_ID = 10; -constexpr int BLOCK_HASH_OFFSET = 40; -constexpr int BLOCK_HASH_SIZE = 32; -const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0')); - QByteArray resource(const QString& path) { QFile file(path); return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); } -QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) -{ - return QJsonDocument(QJsonObject { - { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, - { QStringLiteral("id"), 1 }, - { QStringLiteral("method"), method }, - { QStringLiteral("params"), params }, - }).toJson(QJsonDocument::Compact); -} - -QString blockHashFromResponse(const QByteArray& payload) -{ - QJsonParseError error; - const QJsonDocument document = QJsonDocument::fromJson(payload, &error); - if (error.error != QJsonParseError::NoError || !document.isObject()) - return {}; - const QByteArray block = QByteArray::fromBase64( - document.object().value(QStringLiteral("result")).toString().toLatin1()); - if (block.size() < BLOCK_HASH_OFFSET + BLOCK_HASH_SIZE) - return {}; - return QString::fromLatin1(block.mid(BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE).toHex()); -} - -QString channelIdFromResponse(const QByteArray& payload) -{ - QJsonParseError error; - const QJsonDocument document = QJsonDocument::fromJson(payload, &error); - if (error.error != QJsonParseError::NoError || !document.isObject()) - return {}; - const QString channel = document.object().value(QStringLiteral("result")).toString(); - return ActiveNetwork::isValidIdentity(channel) ? channel : QString(); -} - -QString decimalAdd(const QString& left, const QString& right) -{ - if (left.isEmpty() || right.isEmpty()) - return {}; - if (!std::all_of(left.cbegin(), left.cend(), [](QChar value) { return value.isDigit(); }) - || !std::all_of(right.cbegin(), right.cend(), [](QChar value) { return value.isDigit(); })) { - return {}; - } - QString result; - result.reserve(std::max(left.size(), right.size()) + 1); - qsizetype leftIndex = left.size(); - qsizetype rightIndex = right.size(); - int carry = 0; - while (leftIndex > 0 || rightIndex > 0 || carry > 0) { - const int leftDigit = leftIndex > 0 - ? left.at(--leftIndex).digitValue() : 0; - const int rightDigit = rightIndex > 0 - ? right.at(--rightIndex).digitValue() : 0; - const int sum = leftDigit + rightDigit + carry; - result.prepend(QChar(QLatin1Char('0').unicode() + sum % 10)); - carry = sum / 10; - } - while (result.size() > 1 && result.startsWith(QLatin1Char('0'))) - result.remove(0, 1); - return result; -} - -QJsonObject enumFields(const QJsonValue& value, const QString& variant) +QString environmentValue(const char* primary, const char* fallback) { - return value.toObject().value(variant).toObject(); -} - -WalletAccountRead accountRead(const WalletAccount& account) -{ - WalletAccountRead read; - read.accountId = account.address; - read.status = account.readStatus; - read.programOwner = account.programOwner; - read.dataHex = account.dataHex; - return read; + const QByteArray value = qgetenv(primary); + return QString::fromLocal8Bit(value.isEmpty() ? qgetenv(fallback) : value).trimmed(); } } @@ -142,32 +68,14 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), m_logos(std::make_unique(m_logosAPI)), - m_ownedWallet(std::make_unique(m_logosAPI)), - m_wallet(m_ownedWallet.get()), - m_definitionCache(*m_wallet), + m_wallet(std::make_unique(m_logosAPI)), m_walletController(std::make_unique( *m_wallet, QStringLiteral("AmmUI"))), m_ammClient(std::make_unique()), - m_newPosition(std::make_unique(m_wallet, m_ammClient.get())), - m_swap(std::make_unique(m_wallet, m_ammClient.get())), - m_networkManager(new QNetworkAccessManager(this)), - m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), - m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) -{ - initialize(); -} - -AmmUiBackend::AmmUiBackend(WalletProvider& wallet, QObject* parent) - : AmmUiBackendSimpleSource(parent), - m_logosAPI(nullptr), - m_wallet(&wallet), - m_definitionCache(*m_wallet), - m_walletController(std::make_unique( - *m_wallet, QStringLiteral("AmmUI"))), - m_ammClient(std::make_unique()), - m_newPosition(std::make_unique(m_wallet, m_ammClient.get())), - m_swap(std::make_unique(m_wallet, m_ammClient.get())), - m_networkManager(new QNetworkAccessManager(this)), + m_newPosition(std::make_unique(m_wallet.get(), m_ammClient.get())), + m_swap(std::make_unique(m_wallet.get(), m_ammClient.get())), + m_portfolio(std::make_unique(*m_wallet)), + m_networkProbe(std::make_unique(this)), m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) { @@ -177,21 +85,26 @@ AmmUiBackend::AmmUiBackend(WalletProvider& wallet, QObject* parent) void AmmUiBackend::initialize() { setWalletStateReady(false); - if (m_newPosition) { - setNewPositionContext(m_newPosition->context( - QVariantMap(), networkSnapshot(), false, false)); - } + setNewPositionContext(m_newPosition->context( + QVariantMap(), networkSnapshot(), false, false)); setAssets({}); setAssetStatus(QStringLiteral("idle")); setAssetError({}); - m_network.load(); - m_idlRegistry.registerProgram( - m_network.snapshot().ammProgramId, QStringLiteral("AMM"), m_ammIdl); - publishNetworkState(); + connect(m_networkProbe.get(), &SequencerIdentityProbe::snapshotChanged, + this, [this]() { + publishNetworkState(); + publishNetworkContext(); + refreshPortfolio(); + }); + configureNetworkIdentity(); connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); connect(m_walletController.get(), &WalletController::snapshotChanged, - this, &AmmUiBackend::refreshPortfolio); + this, [this]() { + publishNetworkContext(); + refreshPortfolio(); + }); + publishNetworkState(); syncWalletState(); m_walletController->start(); QTimer::singleShot(0, this, [this]() { @@ -238,11 +151,6 @@ bool AmmUiBackend::openExisting() void AmmUiBackend::disconnectWallet() { m_walletController->disconnect(); - setWalletStateReady(true); - if (m_newPosition) { - m_newPosition->clearWalletAccounts(); - refreshNewPositionContext(QVariantMap()); - } } QString AmmUiBackend::createAccountPublic() @@ -312,19 +220,47 @@ bool AmmUiBackend::setPrimaryAccount(QString accountId) return m_walletController->setPrimaryAccount(accountId); } +void AmmUiBackend::configureNetworkIdentity() +{ + const QString networkId = environmentValue(WALLET_NETWORK_ENV, LEGACY_NETWORK_ENV); + const QString devnetConfig = environmentValue( + WALLET_DEVNET_FILE_ENV, LEGACY_DEVNET_FILE_ENV); + const auto settings = SequencerNetworkSettingsLoader::load(networkId, devnetConfig); + if (!settings) { + qWarning() << "AmmUiBackend: shared network identity configuration is unavailable"; + m_networkProbe->clearConfiguration(); + return; + } + + SequencerIdentityProbe::Request request; + request.endpoint = QUrl(sequencerAddr()); + if (settings->identityMethod == SequencerIdentityMethod::ChannelId) { + request.method = QStringLiteral("getChannelId"); + request.identityFromResult = SequencerIdentityProbe::stringIdentity; + } else { + request.method = QStringLiteral("getBlock"); + request.params = QJsonArray { 10 }; + request.identityFromResult = SequencerIdentityProbe::checkpointBlockHash; + } + m_networkProbe->configure(settings->context, std::move(request)); +} + void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); const bool walletWasOpen = isWalletOpen(); + const bool walletWasReady = walletStateReady(); + const QString previousSyncStatus = walletSyncStatus(); + const QString previousAddress = sequencerAddr(); + const bool wasReachable = sequencerReachable(); if (state.syncStatus == QStringLiteral("opening") || state.syncStatus == QStringLiteral("syncing")) { - m_definitionCache.cancelPending(); + m_portfolio->cancel(); } - const QString previousAddress = sequencerAddr(); - const bool wasReachable = sequencerReachable(); + const bool nextReady = state.syncStatus != QStringLiteral("opening") + && state.syncStatus != QStringLiteral("syncing"); setIsWalletOpen(state.isWalletOpen); - setWalletStateReady(state.syncStatus != QStringLiteral("opening") - && state.syncStatus != QStringLiteral("syncing")); + setWalletStateReady(nextReady); setWalletSyncStatus(state.syncStatus); setWalletSyncError(state.syncError); setWalletCanSubmit(state.canSubmit()); @@ -336,21 +272,34 @@ void AmmUiBackend::syncWalletState() setCurrentBlockHeight(state.currentBlockHeight); setSequencerAddr(state.sequencerAddress); setSequencerReachable(state.sequencerReachable); - if (walletWasOpen && !state.isWalletOpen && m_newPosition) - m_newPosition->clearWalletAccounts(); - - publishNetworkContext(); setPrimaryAccountAddress(state.primaryAccountAddress); setPrimaryAccountName(state.primaryAccountName); + if (walletWasOpen && !state.isWalletOpen) + m_newPosition->clearWalletAccounts(); const bool addressChanged = previousAddress != state.sequencerAddress; - if (addressChanged) - m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); - if (addressChanged || wasReachable != state.sequencerReachable) - m_network.reachabilityChanged(state.sequencerReachable, wasReachable); - publishNetworkState(); - if (state.sequencerReachable && m_network.needsIdentityProbe()) - probeNetworkIdentity(); + m_networkProbe->setEndpoint(QUrl(state.sequencerAddress)); + m_networkProbe->setSequencerAvailable(!state.sequencerAddress.isEmpty()); + m_networkProbe->setReachable(state.sequencerReachable); + m_networkProbe->start(); + + // Cosmetic state changes (aliases and primary-account selection) also emit + // WalletController::stateChanged. They must not rebuild the new-position + // context, which performs synchronous account reads. + const bool lifecycleChanged = walletWasOpen != state.isWalletOpen + || walletWasReady != nextReady + || previousSyncStatus != state.syncStatus + || addressChanged + || wasReachable != state.sequencerReachable; + if (lifecycleChanged) { + publishNetworkState(); + publishNetworkContext(); + // WalletController publishes snapshotChanged before stateChanged. Its + // snapshot observer sees the transient syncing state and blocks the + // portfolio; refresh again after this state update so a ready wallet + // cannot remain blocked until a later network event. + refreshPortfolio(); + } } void AmmUiBackend::publishNetworkContext() @@ -366,9 +315,8 @@ QString AmmUiBackend::ammProgramIdHex() const QByteArray elf = loadAmmElf(); if (elf.isEmpty()) return QString(); - // Hand the deployed program binary to the amm_client program_id op, which - // decodes it and computes the Image ID — 64-char lowercase hex, little-endian - // per u32 word (matches `spel program-id` and the on-chain *_program_id fields). + if (!m_ammClient) + return QString(); const AmmClientResult result = m_ammClient->programId( QJsonObject { { QStringLiteral("elf"), QString::fromLatin1(elf.toHex()) } }); if (!result.ok) { @@ -381,7 +329,8 @@ QString AmmUiBackend::ammProgramIdHex() ActiveNetworkSnapshot AmmUiBackend::networkSnapshot() { ActiveNetworkSnapshot snapshot; - snapshot.id = QStringLiteral("lez"); + const SequencerNetworkSnapshot& sequencer = m_networkProbe->snapshot(); + snapshot.id = sequencer.id; // Defer program/token resolution (which reaches the module) until wallet // state is resolved; the constructor publishes an initial context before // the module is up, and syncWalletState() republishes once it is. @@ -408,14 +357,20 @@ ActiveNetworkSnapshot AmmUiBackend::networkSnapshot() m_networkResolved = true; } snapshot.ammProgramId = m_ammProgramIdCache; - // Bind a quote to this AMM deployment: the program id changes per deployment, - // so it doubles as the network fingerprint (a quote can't be replayed against - // a different program). Empty when AMM_PROGRAM_BIN is unset — status gates it. - snapshot.fingerprint = m_ammProgramIdCache; snapshot.tokenIds = m_tokenIdsCache; - snapshot.status = m_ammProgramIdCache.isEmpty() - ? QStringLiteral("config_missing") - : QStringLiteral("ready"); + if (m_ammProgramIdCache.isEmpty() || m_tokenIdsCache.isEmpty() + || sequencer.status == QStringLiteral("config_missing")) { + snapshot.status = QStringLiteral("config_missing"); + return snapshot; + } + + snapshot.status = sequencer.status; + if (sequencer.status == QStringLiteral("ready")) { + // A quote is valid only for this verified sequencer identity and this + // exact AMM deployment, not either authority in isolation. + snapshot.fingerprint = sequencer.fingerprint + + QStringLiteral("|amm:") + m_ammProgramIdCache; + } return snapshot; } @@ -435,8 +390,6 @@ QString AmmUiBackend::normalizeAccountId(const QString& id) return t.toLower(); } // Try base58 -> hex via the wallet module. - if (!m_logos) - return {}; const QString hex = m_logos->logos_execution_zone.account_id_from_base58(t); return hex.toLower(); // account_id_from_base58 returns "" on failure } @@ -464,6 +417,8 @@ QByteArray AmmUiBackend::loadAmmElf() QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex) { + if (!m_swap) + return {}; return m_swap->resolvePool(defAHex, defBHex, networkSnapshot()); } @@ -471,9 +426,18 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal) { - const QString txHash = m_swap->swap(defAHex, defBHex, userInputHoldingHex, userOutputHoldingHex, - amountInDecimal, minOutDecimal, deadlineDecimal, - networkSnapshot(), isWalletOpen()); + if (!m_swap) + return {}; + const QString txHash = m_swap->swap( + defAHex, + defBHex, + userInputHoldingHex, + userOutputHoldingHex, + amountInDecimal, + minOutDecimal, + deadlineDecimal, + networkSnapshot(), + isWalletOpen()); if (!txHash.isEmpty()) refreshBalances(); return txHash; @@ -540,266 +504,68 @@ QVariantList AmmUiBackend::tokenList() void AmmUiBackend::publishNetworkState() { - const ActiveNetworkSnapshot network = m_network.snapshot(); + const ActiveNetworkSnapshot network = networkSnapshot(); setActiveNetwork(network.id); setNetworkStatus(network.status); setNetworkFingerprint(network.fingerprint); } -void AmmUiBackend::probeNetworkIdentity() -{ - if (m_identityProbeInFlight || !m_network.isConfigured() || sequencerAddr().isEmpty()) - return; - m_identityProbeInFlight = true; - m_network.beginIdentityProbe(); - publishNetworkState(); - const QString address = sequencerAddr(); - const bool devnet = m_network.isDevnet(); - const QString method = devnet ? QStringLiteral("getChannelId") - : QStringLiteral("getBlock"); - const QJsonArray params = devnet ? QJsonArray() - : QJsonArray { CHECKPOINT_BLOCK_ID }; - QNetworkRequest request{QUrl(address)}; - request.setHeader(QNetworkRequest::ContentTypeHeader, - QStringLiteral("application/json")); - request.setTransferTimeout(4000); - QNetworkReply* reply = m_networkManager->post(request, jsonRpcBody(method, params)); - connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { - m_identityProbeInFlight = false; - if (address != sequencerAddr()) { - reply->deleteLater(); - probeNetworkIdentity(); - return; - } - const QByteArray payload = reply->readAll(); - const QString identity = devnet ? channelIdFromResponse(payload) - : blockHashFromResponse(payload); - m_network.finishIdentityProbe(identity); - reply->deleteLater(); - publishNetworkState(); - refreshPortfolio(); - }); -} - void AmmUiBackend::refreshPortfolio() { - const quint64 generation = ++m_portfolioGeneration; if (!m_walletController->state().isWalletOpen) { - m_definitionCache.cancelPending(); + m_portfolio->cancel(); + m_walletController->clearAccountPresentations(); setAssets({}); setAssetStatus(QStringLiteral("idle")); setAssetError({}); return; } - if (m_network.status() != QStringLiteral("ready")) { - invalidateDefinitionCache(); + const ActiveNetworkSnapshot network = networkSnapshot(); + if (network.status != QStringLiteral("ready")) { + m_portfolio->clear(); + m_walletController->clearAccountPresentations(); setAssets({}); setAssetStatus(QStringLiteral("blocked")); - setAssetError(m_network.status()); + setAssetError(network.status); return; } if (m_tokenIdl.isEmpty()) { - invalidateDefinitionCache(); + m_portfolio->clear(); + m_walletController->clearAccountPresentations(); + setAssets({}); setAssetStatus(QStringLiteral("error")); setAssetError(QStringLiteral("token_idl_missing")); return; } - const TokenDefinitionCacheKey key = definitionCacheKey(m_network.snapshot()); + m_portfolio->registerProgram(network.ammProgramId, + QStringLiteral("AMM"), + m_ammIdl); + WalletPortfolioRequest request(m_walletController->snapshot()); + request.sequencerAddress = sequencerAddr(); + request.networkId = network.id; + request.networkFingerprint = network.fingerprint; + request.tokenDefinitionIds = network.tokenIds; + request.tokenIdl = m_tokenIdl; setAssetStatus(QStringLiteral("loading")); setAssetError({}); - if (m_appliedDefinitionKey && *m_appliedDefinitionKey == key - && m_definitionCache.contains(key)) { - applyWalletPortfolio(generation); - return; - } - m_definitionCache.read( - key, - [this, generation, key](QVector reads) { - applyDefinitions(generation, key, reads); - }); -} - -TokenDefinitionCacheKey AmmUiBackend::definitionCacheKey( - const ActiveNetworkSnapshot& network) const -{ - return { - network.id, - network.fingerprint, - sequencerAddr(), - network.tokenIds, - }; -} - -void AmmUiBackend::invalidateDefinitionCache() -{ - m_definitionCache.clear(); - m_appliedDefinitionKey.reset(); -} - -void AmmUiBackend::applyDefinitions( - quint64 generation, - const TokenDefinitionCacheKey& key, - const QVector& reads) -{ - if (generation != m_portfolioGeneration) - return; - const ActiveNetworkSnapshot network = m_network.snapshot(); - if (!(key == definitionCacheKey(network))) - return; - const WalletDecodeResult decoded = WalletIdlDecoder::decode(m_tokenIdl, reads); - if (!decoded.ok() || reads.size() != network.tokenIds.size() - || decoded.accounts.size() != reads.size()) { - invalidateDefinitionCache(); - setAssetStatus(QStringLiteral("error")); - setAssetError(decoded.error.isEmpty() - ? QStringLiteral("definition_decode_failed") - : decoded.error); - return; - } - - m_tokens.clear(); - m_tokenProgramId.clear(); - int unavailable = 0; - for (qsizetype index = 0; index < reads.size(); ++index) { - const WalletAccountRead& read = reads.at(index); - const WalletDecodedAccount& account = decoded.accounts.at(index); - TokenInfo token; - token.id = network.tokenIds.at(index); - token.name = QStringLiteral("Unknown token"); - token.status = QStringLiteral("unavailable"); - const QJsonObject fungible = enumFields(account.value, QStringLiteral("Fungible")); - if (read.ok() && account.status == QStringLiteral("decoded") - && account.typeName == QStringLiteral("TokenDefinition") - && !fungible.isEmpty() && read.programOwner != DEFAULT_PROGRAM_OWNER) { - token.name = fungible.value(QStringLiteral("name")).toString().trimmed(); - if (token.name.isEmpty()) - token.name = QStringLiteral("Unnamed token"); - token.programOwner = read.programOwner; - token.status = QStringLiteral("ready"); - if (m_tokenProgramId.isEmpty()) - m_tokenProgramId = read.programOwner; - else if (m_tokenProgramId != read.programOwner) { - invalidateDefinitionCache(); - setAssets({}); - setAssetStatus(QStringLiteral("error")); - setAssetError(QStringLiteral("token_program_mismatch")); - return; - } - } else { - ++unavailable; - } - m_tokens.append(std::move(token)); - } - if (m_tokenProgramId.isEmpty()) { - invalidateDefinitionCache(); - setAssets({}); - setAssetStatus(QStringLiteral("error")); - setAssetError(QStringLiteral("definitions_unavailable")); - return; - } - m_idlRegistry.registerProgram( - m_tokenProgramId, QStringLiteral("Token"), m_tokenIdl); - if (unavailable > 0) - invalidateDefinitionCache(); - else - m_appliedDefinitionKey = key; - setAssetError(unavailable > 0 - ? QStringLiteral("some_definitions_unavailable") - : QString()); - applyWalletPortfolio(generation); + const QPointer guard(this); + m_portfolio->refresh(std::move(request), [guard](WalletPortfolioResult result) { + if (guard) + guard->applyPortfolio(std::move(result)); + }); } -void AmmUiBackend::applyWalletPortfolio(quint64 generation) +void AmmUiBackend::applyPortfolio(WalletPortfolioResult result) { - if (generation != m_portfolioGeneration) + if (!isWalletOpen()) return; - const WalletSnapshot snapshot = m_walletController->snapshot(); - QVector programReads; - for (const WalletAccount& account : snapshot.accounts) { - if (!account.isPublic || account.readStatus != QStringLiteral("ok")) - continue; - programReads.append(accountRead(account)); - } - QHash balances; - QVector presentations; - const QVector programs = m_idlRegistry.decode(programReads); - for (const WalletDecodedProgram& program : programs) { - for (const WalletDecodedAccount& account : program.result.accounts) { - WalletAccountPresentation presentation; - presentation.address = account.id; - presentation.programName = program.programName; - presentation.accountType = account.typeName; - if (program.programId == m_tokenProgramId - && account.typeName == QStringLiteral("TokenHolding")) { - const QJsonObject fungible = enumFields( - account.value, QStringLiteral("Fungible")); - if (fungible.isEmpty()) - continue; - const QString encodedId = fungible - .value(QStringLiteral("definition_id")).toString(); - const QString definitionId = account.accountIds.value(encodedId); - const QString amount = fungible.value(QStringLiteral("balance")).toString(); - const QString current = balances.value(definitionId, QStringLiteral("0")); - const QString total = decimalAdd(current, amount); - if (!definitionId.isEmpty() && !total.isEmpty()) - balances.insert(definitionId, total); - presentation.kind = QStringLiteral("token_holding"); - presentation.definitionId = definitionId; - presentation.hiddenFromAccounts = true; - for (const TokenInfo& token : m_tokens) { - if (token.id == definitionId) { - presentation.semanticName = token.name + QStringLiteral(" holding"); - break; - } - } - } else if (program.programId == m_tokenProgramId - && account.typeName == QStringLiteral("TokenDefinition")) { - presentation.kind = QStringLiteral("token_definition"); - const QJsonObject fungible = enumFields( - account.value, QStringLiteral("Fungible")); - presentation.semanticName = fungible.value(QStringLiteral("name")).toString(); - } else if (program.programId == m_tokenProgramId - && account.typeName == QStringLiteral("TokenMetadata")) { - presentation.kind = QStringLiteral("token_metadata"); - } else { - presentation.kind = QStringLiteral("program"); - presentation.semanticName = account.typeName; - } - presentations.append(std::move(presentation)); - } - } - m_walletController->applyAccountPresentations(presentations); - - QVariantList assets; - QVariantList available; - int unavailableCount = 0; - for (const TokenInfo& token : m_tokens) { - const QString balance = balances.value(token.id, QStringLiteral("0")); - const bool positive = balance != QStringLiteral("0"); - QString displayDefinitionId = walletAccountIdToBase58(token.id); - if (displayDefinitionId.isEmpty()) - displayDefinitionId = token.id; - QVariantMap asset { - { QStringLiteral("name"), token.name }, - { QStringLiteral("symbol"), token.name }, - { QStringLiteral("balance"), balance }, - { QStringLiteral("definitionId"), token.id }, - { QStringLiteral("displayDefinitionId"), displayDefinitionId }, - { QStringLiteral("programOwner"), token.programOwner }, - { QStringLiteral("status"), token.status }, - { QStringLiteral("section"), positive ? QStringLiteral("assets") - : QStringLiteral("available") }, - }; - if (positive) - assets.append(std::move(asset)); - else - available.append(std::move(asset)); - if (token.status != QStringLiteral("ready")) - ++unavailableCount; - } - assets.append(available); - setAssets(assets); - setAssetStatus(unavailableCount > 0 ? QStringLiteral("partial") - : QStringLiteral("ready")); + // A partial/failed decode can omit an account. Clear stale enrichment + // first so a former token holding cannot remain hidden or display an old + // balance while the shared portfolio service reports it unavailable. + m_walletController->clearAccountPresentations(); + m_walletController->applyAccountPresentations(result.presentations); + setAssets(std::move(result.assets)); + setAssetStatus(std::move(result.status)); + setAssetError(std::move(result.error)); } diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 46324c95..c56d06d3 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -2,24 +2,19 @@ #define AMM_UI_BACKEND_H #include -#include #include -#include #include #include #include #include #include #include -#include #include "rep_AmmUiBackend_source.h" #include "ActiveNetwork.h" -#include "TokenDefinitionCache.h" #include "WalletAccountModel.h" -#include "WalletIdlDecoder.h" class LogosAPI; struct LogosModules; @@ -27,8 +22,10 @@ class AmmClient; class LogosWalletProvider; class NewPositionRuntime; class SwapRuntime; -class QNetworkAccessManager; +class SequencerIdentityProbe; class WalletController; +class WalletPortfolioService; +struct WalletPortfolioResult; // Source-side implementation of the AmmUiBackend .rep interface. // Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and @@ -39,8 +36,6 @@ class AmmUiBackend : public AmmUiBackendSimpleSource { public: explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); - // The injected provider must outlive the backend. - explicit AmmUiBackend(WalletProvider& wallet, QObject* parent = nullptr); ~AmmUiBackend() override; WalletAccountModel* accountModel() const; @@ -74,20 +69,10 @@ public slots: QVariantList tokenList() override; private: - struct TokenInfo { - QString id; - QString name; - QString programOwner; - QString status; - }; - void syncWalletState(); void publishNetworkContext(); - // Builds the new-position network context from the same sources the Swap - // view uses: ammProgramId from $AMM_PROGRAM_BIN, tokenIds from - // $TOKENS_CONFIG. status is "ready" once AMM_PROGRAM_BIN resolves, else - // "config_missing". There is no separate network config or channel probe. + // Combines shared, verified sequencer identity with AMM deployment inputs. ActiveNetworkSnapshot networkSnapshot(); // 64-char lowercase-hex AMM program id derived from $AMM_PROGRAM_BIN (empty @@ -105,15 +90,9 @@ public slots: QByteArray loadAmmElf(); void publishNetworkState(); void initialize(); - void probeNetworkIdentity(); + void configureNetworkIdentity(); void refreshPortfolio(); - TokenDefinitionCacheKey definitionCacheKey( - const ActiveNetworkSnapshot& network) const; - void invalidateDefinitionCache(); - void applyDefinitions(quint64 generation, - const TokenDefinitionCacheKey& key, - const QVector& reads); - void applyWalletPortfolio(quint64 generation); + void applyPortfolio(WalletPortfolioResult result); LogosAPI* m_logosAPI; // Direct module handle for the AMM/swap path (resolvePool/swapExactInput/ @@ -122,34 +101,23 @@ public slots: // calls the AMM path needs, so keep a thin LogosModules over the same // LogosAPI as the wallet provider. std::unique_ptr m_logos; - std::unique_ptr m_ownedWallet; - WalletProvider* m_wallet; - TokenDefinitionCache m_definitionCache; + std::unique_ptr m_wallet; std::unique_ptr m_walletController; std::unique_ptr m_ammClient; std::unique_ptr m_newPosition; std::unique_ptr m_swap; - std::unique_ptr m_swap; + std::unique_ptr m_portfolio; + std::unique_ptr m_networkProbe; QVariantMap m_newPositionHints; - // Network context is derived from $AMM_PROGRAM_BIN + $TOKENS_CONFIG, which are - // fixed for the process lifetime — resolve them once and cache. networkSnapshot() - // runs on the hot path (every quote) and from inside runtime callbacks, and - // tokenList() makes remote base58 conversions, so it must not recompute each call. + // AMM deployment inputs are fixed for the process lifetime. Resolve once: + // networkSnapshot() runs on quote hot paths and tokenList() uses the module. bool m_networkResolved = false; QString m_ammProgramIdCache; QStringList m_tokenIdsCache; - QNetworkAccessManager* m_networkManager; - ActiveNetwork m_network; QByteArray m_tokenIdl; QByteArray m_ammIdl; - WalletIdlRegistry m_idlRegistry; - QVector m_tokens; - QString m_tokenProgramId; - std::optional m_appliedDefinitionKey; - bool m_identityProbeInFlight = false; - quint64 m_portfolioGeneration = 0; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/tests/cpp/ActiveNetworkTest.cpp b/apps/amm/tests/cpp/ActiveNetworkTest.cpp deleted file mode 100644 index 0f5250ca..00000000 --- a/apps/amm/tests/cpp/ActiveNetworkTest.cpp +++ /dev/null @@ -1,47 +0,0 @@ -#include "ActiveNetwork.h" - -#include -#include -#include -#include -#include - -class ActiveNetworkTest : public QObject { - Q_OBJECT - -private slots: - void validatesIdentityBeforeReadiness(); -}; - -void ActiveNetworkTest::validatesIdentityBeforeReadiness() -{ - const QString identity(64, QLatin1Char('a')); - const QString programId(64, QLatin1Char('b')); - const QString tokenId(64, QLatin1Char('c')); - QTemporaryFile config; - QVERIFY(config.open()); - config.write(QJsonDocument(QJsonObject { - { QStringLiteral("channelId"), identity }, - { QStringLiteral("ammProgramId"), programId }, - { QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } }, - }).toJson(QJsonDocument::Compact)); - config.flush(); - qputenv("AMM_UI_NETWORK", "devnet"); - qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit()); - - ActiveNetwork network; - QVERIFY(network.load()); - QCOMPARE(network.status(), QStringLiteral("network_unknown")); - network.sequencerChanged(true); - network.finishIdentityProbe(QString(64, QLatin1Char('d'))); - QCOMPARE(network.status(), QStringLiteral("network_mismatch")); - network.reachabilityChanged(false, true); - network.reachabilityChanged(true, false); - network.finishIdentityProbe(identity); - QCOMPARE(network.status(), QStringLiteral("ready")); - QCOMPARE(network.snapshot().fingerprint, QStringLiteral("channel:") + identity); - QCOMPARE(network.snapshot().tokenIds, QStringList { tokenId }); -} - -QTEST_GUILESS_MAIN(ActiveNetworkTest) -#include "ActiveNetworkTest.moc" diff --git a/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp b/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp deleted file mode 100644 index 28e1bcbb..00000000 --- a/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp +++ /dev/null @@ -1,242 +0,0 @@ -#include "AmmUiBackend.h" -#include "FakeWalletProvider.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace { -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; -}; - -class LocalRpcServer final { -public: - explicit LocalRpcServer(QString channelId) - : m_channelId(std::move(channelId)) - { - QObject::connect(&m_server, &QTcpServer::newConnection, [&]() { - while (m_server.hasPendingConnections()) { - QTcpSocket* socket = m_server.nextPendingConnection(); - QObject::connect(socket, &QTcpSocket::readyRead, socket, - [this, socket]() { process(socket); }); - if (socket->bytesAvailable() > 0) - process(socket); - } - }); - } - - bool listen() - { - return m_server.listen(QHostAddress::LocalHost); - } - - QString endpoint() const - { - return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()); - } - -private: - void process(QTcpSocket* socket) - { - QByteArray& request = m_requests[socket]; - request.append(socket->readAll()); - const qsizetype headerEnd = request.indexOf("\r\n\r\n"); - if (headerEnd < 0) - return; - - qsizetype contentLength = 0; - for (QByteArray line : request.first(headerEnd).split('\n')) { - line = line.trimmed(); - if (line.toLower().startsWith("content-length:")) { - contentLength = line.mid(sizeof("content-length:") - 1) - .trimmed().toLongLong(); - } - } - if (request.size() - headerEnd - 4 < contentLength) - return; - - m_requests.remove(socket); - const QByteArray payload = QJsonDocument(QJsonObject { - { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, - { QStringLiteral("id"), 1 }, - { QStringLiteral("result"), m_channelId }, - }).toJson(QJsonDocument::Compact); - QByteArray response = QByteArrayLiteral( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: "); - response += QByteArray::number(payload.size()); - response += QByteArrayLiteral("\r\nConnection: close\r\n\r\n"); - response += payload; - socket->write(response); - socket->disconnectFromHost(); - } - - QTcpServer m_server; - QHash m_requests; - QString m_channelId; -}; - -QByteArray devnetConfig(const QString& channelId, - const QString& ammProgramId, - const QString& definitionId) -{ - return QJsonDocument(QJsonObject { - { QStringLiteral("channelId"), channelId }, - { QStringLiteral("ammProgramId"), ammProgramId }, - { QStringLiteral("tokenDefinitionIds"), QJsonArray { definitionId } }, - }).toJson(QJsonDocument::Compact); -} - -class BackendFixture final { -public: - BackendFixture() - : channelId(64, QLatin1Char('a')), - ammProgramId(64, QLatin1Char('b')), - definitionId(64, QLatin1Char('c')), - tokenProgramId(64, QLatin1Char('d')), - server(channelId) - { - } - - bool initialize(bool deferDefinitionReads = false) - { - if (!server.listen() || !directory.isValid()) - return false; - const QString walletHome = directory.filePath(QStringLiteral("wallet")); - if (!QDir().mkpath(walletHome)) - return false; - const QString configPath = directory.filePath(QStringLiteral("devnet.json")); - QFile config(configPath); - if (!config.open(QIODevice::WriteOnly)) - return false; - const QByteArray configData = devnetConfig(channelId, ammProgramId, definitionId); - if (config.write(configData) != qint64(configData.size())) - return false; - config.close(); - - network = std::make_unique( - QByteArrayLiteral("AMM_UI_NETWORK"), QByteArrayLiteral("devnet")); - devnetFile = std::make_unique( - QByteArrayLiteral("AMM_UI_DEVNET_FILE"), configPath.toLocal8Bit()); - walletHomeEnvironment = std::make_unique( - QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit()); - settingsHome = std::make_unique( - QByteArrayLiteral("XDG_CONFIG_HOME"), - directory.filePath(QStringLiteral("settings")).toLocal8Bit()); - QSettings settings(QStringLiteral("Logos"), QStringLiteral("AmmUI")); - settings.setValue(QStringLiteral("disconnected"), false); - settings.sync(); - - provider.connectResult.adopted = true; - provider.connectResult.snapshot.sequencerAddress = server.endpoint(); - provider.snapshotResult = provider.connectResult.snapshot; - provider.readResult.status = QStringLiteral("ok"); - provider.readResult.programOwner = tokenProgramId; - provider.readResult.dataHex = QStringLiteral( - "0004000000544553540a0000000000000000000000000000000000"); - provider.deferPublicAccountReads = deferDefinitionReads; - backend = std::make_unique(provider); - return true; - } - - QString channelId; - QString ammProgramId; - QString definitionId; - QString tokenProgramId; - LocalRpcServer server; - QTemporaryDir directory; - std::unique_ptr network; - std::unique_ptr devnetFile; - std::unique_ptr walletHomeEnvironment; - std::unique_ptr settingsHome; - FakeWalletProvider provider; - std::unique_ptr backend; -}; -} - -class AmmUiBackendDefinitionCacheTest : public QObject { - Q_OBJECT - -private slots: - void reusesDefinitionsAfterRefreshAndReopen(); - void restartsDefinitionReadAfterRefreshAndReopen(); -}; - -void AmmUiBackendDefinitionCacheTest::reusesDefinitionsAfterRefreshAndReopen() -{ - BackendFixture fixture; - QVERIFY(fixture.initialize()); - - QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready")); - QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); - QCOMPARE(fixture.provider.publicAccountReadCalls, 1); - QCOMPARE(fixture.provider.lastPublicAccountIds, - QStringList { fixture.definitionId }); - - fixture.backend->refreshBalances(); - QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); - QCOMPARE(fixture.provider.publicAccountReadCalls, 1); - - fixture.backend->disconnectWallet(); - QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed")); - QVERIFY(fixture.backend->openExisting()); - QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); - QCOMPARE(fixture.provider.publicAccountReadCalls, 1); -} - -void AmmUiBackendDefinitionCacheTest::restartsDefinitionReadAfterRefreshAndReopen() -{ - BackendFixture fixture; - QVERIFY(fixture.initialize(true)); - - QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready")); - QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 1); - QCOMPARE(fixture.backend->assetStatus(), QStringLiteral("loading")); - - fixture.backend->refreshBalances(); - QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 2); - - fixture.backend->disconnectWallet(); - QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed")); - QVERIFY(fixture.backend->openExisting()); - QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 3); - - fixture.provider.completePendingPublicAccountReads(); - QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); - QCOMPARE(fixture.provider.publicAccountReadCalls, 3); -} - -QTEST_GUILESS_MAIN(AmmUiBackendDefinitionCacheTest) -#include "AmmUiBackendDefinitionCacheTest.moc" diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 8c32c116..568a0d83 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -8,6 +8,8 @@ endif() option(LOGOS_WALLET_BUILD_QML "Build the Logos.Wallet QML module" ON) option(LOGOS_WALLET_BUILD_ACCESS "Build the generated-SDK wallet adapter" ON) set(LOGOS_WALLET_GENERATED_DIR "" CACHE PATH "Path to generated Logos SDK sources") +set(LOGOS_WALLET_IDL_DECODER_LIBRARY "" CACHE FILEPATH + "wallet_idl_decoder library required by logos_wallet_access") if(LOGOS_WALLET_BUILD_ACCESS AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/logos_sdk.h" @@ -24,6 +26,18 @@ endif() set(CMAKE_AUTOMOC ON) if(LOGOS_WALLET_BUILD_ACCESS) + if(NOT LOGOS_WALLET_IDL_DECODER_LIBRARY) + find_library(LOGOS_WALLET_IDL_DECODER_LIBRARY + NAMES wallet_idl_decoder + HINTS "$ENV{LOGOS_EXT_ROOT_WALLET_IDL_DECODER}/lib" + ) + endif() + if(NOT LOGOS_WALLET_IDL_DECODER_LIBRARY) + message(FATAL_ERROR + "logos_wallet_access requires wallet_idl_decoder; set " + "LOGOS_WALLET_IDL_DECODER_LIBRARY to its library path" + ) + endif() add_library(logos_wallet_access STATIC src/WalletProvider.h src/WalletProvider.cpp @@ -35,6 +49,18 @@ if(LOGOS_WALLET_BUILD_ACCESS) src/WalletAccountModel.cpp src/WalletController.h src/WalletController.cpp + src/TokenDefinitionCache.h + src/TokenDefinitionCache.cpp + src/WalletIdlDecoder.h + src/WalletIdlDecoder.cpp + src/SequencerNetworkContext.h + src/SequencerNetworkContext.cpp + src/SequencerNetworkSettings.h + src/SequencerNetworkSettings.cpp + src/SequencerIdentityProbe.h + src/SequencerIdentityProbe.cpp + src/WalletPortfolioService.h + src/WalletPortfolioService.cpp ) set_target_properties(logos_wallet_access PROPERTIES AUTOMOC ON @@ -50,7 +76,14 @@ if(LOGOS_WALLET_BUILD_ACCESS) ) target_link_libraries(logos_wallet_access PUBLIC Qt6::Core - PRIVATE Qt6::Network + PRIVATE + Qt6::Network + "${LOGOS_WALLET_IDL_DECODER_LIBRARY}" + ) + qt_add_resources(logos_wallet_access logos_wallet_access_network_data + PREFIX "/wallet" + FILES + config/networks.json ) endif() @@ -163,6 +196,117 @@ if(BUILD_TESTING) ) add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test) + if(LOGOS_WALLET_BUILD_ACCESS) + add_executable(logos_wallet_idl_decoder_link_test + tests/cpp/WalletIdlDecoderLinkTest.cpp + ) + target_compile_features(logos_wallet_idl_decoder_link_test PRIVATE cxx_std_17) + target_link_libraries(logos_wallet_idl_decoder_link_test PRIVATE + Qt6::Core + Qt6::Test + logos_wallet_access + ) + add_test(NAME logos_wallet_idl_decoder_link + COMMAND logos_wallet_idl_decoder_link_test) + endif() + + add_executable(logos_wallet_token_definition_cache_test + tests/cpp/TokenDefinitionCacheTest.cpp + src/TokenDefinitionCache.h + src/TokenDefinitionCache.cpp + ) + set_target_properties(logos_wallet_token_definition_cache_test PROPERTIES AUTOMOC ON) + target_compile_features(logos_wallet_token_definition_cache_test PRIVATE cxx_std_17) + target_include_directories(logos_wallet_token_definition_cache_test PRIVATE + tests/support + src + ) + target_link_libraries(logos_wallet_token_definition_cache_test PRIVATE + Qt6::Core + Qt6::Test + ) + add_test(NAME logos_wallet_token_definition_cache + COMMAND logos_wallet_token_definition_cache_test) + + add_executable(logos_wallet_sequencer_network_context_test + tests/cpp/SequencerNetworkContextTest.cpp + src/SequencerNetworkContext.h + src/SequencerNetworkContext.cpp + ) + set_target_properties(logos_wallet_sequencer_network_context_test PROPERTIES AUTOMOC ON) + target_compile_features(logos_wallet_sequencer_network_context_test PRIVATE cxx_std_17) + target_include_directories(logos_wallet_sequencer_network_context_test PRIVATE src) + target_link_libraries(logos_wallet_sequencer_network_context_test PRIVATE + Qt6::Core + Qt6::Test + ) + add_test(NAME logos_wallet_sequencer_network_context + COMMAND logos_wallet_sequencer_network_context_test) + + add_executable(logos_wallet_sequencer_network_settings_test + tests/cpp/SequencerNetworkSettingsTest.cpp + src/SequencerNetworkContext.h + src/SequencerNetworkContext.cpp + src/SequencerNetworkSettings.h + src/SequencerNetworkSettings.cpp + ) + set_target_properties(logos_wallet_sequencer_network_settings_test PROPERTIES AUTOMOC ON) + target_compile_features(logos_wallet_sequencer_network_settings_test PRIVATE cxx_std_17) + target_include_directories(logos_wallet_sequencer_network_settings_test PRIVATE src) + target_link_libraries(logos_wallet_sequencer_network_settings_test PRIVATE + Qt6::Core + Qt6::Test + ) + qt_add_resources(logos_wallet_sequencer_network_settings_test + logos_wallet_access_network_data + PREFIX "/wallet" + FILES + config/networks.json + ) + add_test(NAME logos_wallet_sequencer_network_settings + COMMAND logos_wallet_sequencer_network_settings_test) + + add_executable(logos_wallet_sequencer_identity_probe_test + tests/cpp/SequencerIdentityProbeTest.cpp + src/SequencerIdentityProbe.h + src/SequencerIdentityProbe.cpp + src/SequencerNetworkContext.h + src/SequencerNetworkContext.cpp + ) + set_target_properties(logos_wallet_sequencer_identity_probe_test PROPERTIES AUTOMOC ON) + target_compile_features(logos_wallet_sequencer_identity_probe_test PRIVATE cxx_std_17) + target_include_directories(logos_wallet_sequencer_identity_probe_test PRIVATE src) + target_link_libraries(logos_wallet_sequencer_identity_probe_test PRIVATE + Qt6::Core + Qt6::Network + Qt6::Test + ) + add_test(NAME logos_wallet_sequencer_identity_probe + COMMAND logos_wallet_sequencer_identity_probe_test) + + add_executable(logos_wallet_portfolio_service_test + tests/cpp/WalletPortfolioServiceTest.cpp + src/WalletPortfolioService.h + src/WalletPortfolioService.cpp + src/TokenDefinitionCache.h + src/TokenDefinitionCache.cpp + src/WalletAccountId.h + src/WalletAccountId.cpp + src/WalletProvider.cpp + ) + set_target_properties(logos_wallet_portfolio_service_test PROPERTIES AUTOMOC ON) + target_compile_features(logos_wallet_portfolio_service_test PRIVATE cxx_std_17) + target_include_directories(logos_wallet_portfolio_service_test PRIVATE + tests/support + src + ) + target_link_libraries(logos_wallet_portfolio_service_test PRIVATE + Qt6::Core + Qt6::Test + ) + add_test(NAME logos_wallet_portfolio_service + COMMAND logos_wallet_portfolio_service_test) + if(LOGOS_WALLET_BUILD_QML) find_package(Qt6 6.8 REQUIRED COMPONENTS QuickTest) add_executable(logos_wallet_qml_test tests/qml/main.cpp) diff --git a/apps/shared/wallet/config/networks.json b/apps/shared/wallet/config/networks.json new file mode 100644 index 00000000..0fd43da9 --- /dev/null +++ b/apps/shared/wallet/config/networks.json @@ -0,0 +1,5 @@ +{ + "testnet": { + "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a" + } +} diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml index 1536781d..eefa31ee 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -8,6 +8,8 @@ Item { id: root property var wallet: null + property var portfolio: null + property var network: null property var accountModel: null property var watchCall: null property bool compact: false @@ -35,7 +37,20 @@ Item { readonly property string selectedName: root.accountAt(root.selectedIndex, "name") readonly property string selectedBalance: root.accountAt(root.selectedIndex, "balance") readonly property bool selectedIsPublic: root.accountAt(root.selectedIndex, "isPublic") === true - readonly property var walletAssets: root.wallet && root.wallet.assets ? root.wallet.assets : [] + readonly property var portfolioProvider: root.portfolio ? root.portfolio : root.wallet + readonly property var networkProvider: root.network ? root.network : root.wallet + readonly property string activeNetwork: root.networkProvider && root.networkProvider.activeNetwork + ? root.networkProvider.activeNetwork : "" + readonly property string networkStatus: root.networkProvider + && root.networkProvider.networkStatus !== undefined + ? root.networkProvider.networkStatus : "" + readonly property string assetStatus: root.portfolioProvider + && root.portfolioProvider.assetStatus !== undefined + ? root.portfolioProvider.assetStatus : "" + readonly property string assetError: root.portfolioProvider && root.portfolioProvider.assetError + ? root.portfolioProvider.assetError : "" + readonly property var walletAssets: root.portfolioProvider && root.portfolioProvider.assets + ? root.portfolioProvider.assets : [] readonly property int availableAssetCount: root.assetCount("available") readonly property string primaryName: root.wallet && root.wallet.primaryAccountName ? root.wallet.primaryAccountName : root.selectedName @@ -384,10 +399,11 @@ Item { Layout.preferredWidth: 8 Layout.preferredHeight: 8 radius: 4 - color: !root.wallet || root.wallet.networkStatus === undefined - || root.wallet.networkStatus === "ready" + objectName: "walletNetworkStatusIndicator" + color: !root.networkProvider || root.networkStatus.length === 0 + || root.networkStatus === "ready" ? "#22c55e" - : root.wallet.networkStatus === "loading" ? "#f59e0b" : "#ef4444" + : root.networkStatus === "loading" ? "#f59e0b" : "#ef4444" } Label { id: accountButtonLabel @@ -500,8 +516,8 @@ Item { font.pixelSize: 16 } Label { - text: root.wallet && root.wallet.activeNetwork - ? root.wallet.activeNetwork : qsTr("Network unavailable") + objectName: "walletNetworkName" + text: root.activeNetwork || qsTr("Network unavailable") color: "#a1a1aa" font.pixelSize: 11 } @@ -583,22 +599,29 @@ Item { font.bold: true } Label { - visible: root.wallet && root.wallet.assetStatus === "loading" + objectName: "walletAssetsLoadingLabel" + visible: root.portfolioProvider && root.assetStatus === "loading" text: qsTr("Loading balances…") color: "#a1a1aa" } Repeater { + objectName: "walletAssetRepeater" model: root.walletAssets delegate: Rectangle { required property var modelData + objectName: "walletAssetBox" Layout.fillWidth: true visible: modelData.section === "assets" - implicitHeight: visible ? 62 : 0 + implicitHeight: visible ? 68 : 0 color: "#27272a" - radius: 8 + radius: 10 + border.width: 1 + border.color: "#3f3f46" + Accessible.name: qsTr("%1 token, balance %2") + .arg(modelData.name).arg(modelData.balance) RowLayout { anchors.fill: parent - anchors.margins: 10 + anchors.margins: 12 ColumnLayout { Layout.fillWidth: true spacing: 1 @@ -633,9 +656,9 @@ Item { } Label { visible: (!root.walletAssets || root.walletAssets.length === 0) - && (!root.wallet || root.wallet.assetStatus !== "loading") - text: root.wallet && root.wallet.assetError - ? qsTr("Assets unavailable: %1").arg(root.wallet.assetError) + && (!root.portfolioProvider || root.assetStatus !== "loading") + text: root.assetError + ? qsTr("Assets unavailable: %1").arg(root.assetError) : qsTr("No assets yet") color: "#a1a1aa" wrapMode: Text.Wrap @@ -650,17 +673,23 @@ Item { onClicked: root.availableExpanded = !root.availableExpanded } Repeater { + objectName: "walletAvailableAssetRepeater" model: root.walletAssets delegate: Rectangle { required property var modelData + objectName: "walletAvailableAssetBox" Layout.fillWidth: true visible: root.availableExpanded && modelData.section === "available" - implicitHeight: visible ? 58 : 0 + implicitHeight: visible ? 64 : 0 color: "#202023" - radius: 8 + radius: 10 + border.width: 1 + border.color: "#3f3f46" + Accessible.name: qsTr("%1 token, no balance") + .arg(modelData.name) RowLayout { anchors.fill: parent - anchors.margins: 10 + anchors.margins: 12 ColumnLayout { Layout.fillWidth: true spacing: 1 @@ -745,6 +774,7 @@ Item { required property string section required property string programName required property string accountType + required property string decodedData required property string visibility required property bool canBePrimary required property bool isPrimary @@ -774,6 +804,7 @@ Item { section: accountWrapper.section programName: accountWrapper.programName accountType: accountWrapper.accountType + decodedData: accountWrapper.decodedData visibility: accountWrapper.visibility canBePrimary: accountWrapper.canBePrimary isPrimary: accountWrapper.isPrimary diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml index ea5da2ba..43a2f211 100644 --- a/apps/shared/wallet/qml/internal/AccountDelegate.qml +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -16,6 +16,7 @@ ItemDelegate { required property string section required property string programName required property string accountType + required property string decodedData required property string visibility required property bool canBePrimary required property bool isPrimary @@ -87,14 +88,51 @@ ItemDelegate { } Label { - visible: root.programName.length > 0 + objectName: "walletProgramName" + visible: root.section === "advanced" && root.programName.length > 0 Layout.fillWidth: true - text: qsTr("%1 program · wallet controlled").arg(root.programName) + text: qsTr("Program: %1").arg(root.programName) color: "#a1a1aa" font.pixelSize: 11 elide: Text.ElideRight } + ColumnLayout { + visible: root.section === "advanced" && root.decodedData.length > 0 + Layout.fillWidth: true + spacing: 4 + + Label { + objectName: "walletDecodedDataLabel" + text: qsTr("Decoded data") + color: "#a1a1aa" + font.pixelSize: 11 + } + + Rectangle { + objectName: "walletDecodedDataBox" + Layout.fillWidth: true + implicitHeight: decodedDataText.implicitHeight + 16 + color: "#18181b" + radius: 6 + border.width: 1 + border.color: "#3f3f46" + + Text { + id: decodedDataText + objectName: "walletDecodedData" + anchors.fill: parent + anchors.margins: 8 + text: root.decodedData + color: "#d4d4d8" + font.family: "monospace" + font.pixelSize: 10 + textFormat: Text.PlainText + wrapMode: Text.WrapAnywhere + } + } + } + RowLayout { Layout.fillWidth: true spacing: 4 diff --git a/apps/shared/wallet/src/SequencerIdentityProbe.cpp b/apps/shared/wallet/src/SequencerIdentityProbe.cpp new file mode 100644 index 00000000..0056aeec --- /dev/null +++ b/apps/shared/wallet/src/SequencerIdentityProbe.cpp @@ -0,0 +1,285 @@ +#include "SequencerIdentityProbe.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int REQUEST_TIMEOUT_MILLISECONDS = 4000; +constexpr int INITIAL_RETRY_DELAY_MILLISECONDS = 250; +constexpr int MAX_RETRY_DELAY_MILLISECONDS = 4000; +constexpr qsizetype CHECKPOINT_BLOCK_HASH_OFFSET = 40; +constexpr qsizetype CHECKPOINT_BLOCK_HASH_SIZE = 32; + +QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) +{ + return QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("method"), method }, + { QStringLiteral("params"), params }, + }).toJson(QJsonDocument::Compact); +} + +bool hasSuccessStatus(const QVariant& status) +{ + if (!status.isValid()) + return false; + const int code = status.toInt(); + return code >= 200 && code < 300; +} +} + +SequencerIdentityProbe::SequencerIdentityProbe(QObject* parent) + : QObject(parent), + m_network(new QNetworkAccessManager(this)), + m_retryTimer(new QTimer(this)) +{ + m_retryTimer->setSingleShot(true); + connect(m_retryTimer, &QTimer::timeout, this, &SequencerIdentityProbe::start); +} + +SequencerIdentityProbe::~SequencerIdentityProbe() +{ + cancelPendingWork(); +} + +bool SequencerIdentityProbe::configure(SequencerNetworkContext::Configuration network, + Request request) +{ + cancelPendingWork(); + m_networkConfiguration = std::move(network); + m_request = std::move(request); + m_requestConfigured = isValidRequest(m_request) + && m_context.configure(m_networkConfiguration); + if (!m_requestConfigured) { + if (m_context.isConfigured()) + m_context.clearConfiguration(); + emit snapshotChanged(); + return false; + } + + updateContextAvailability(); + emit snapshotChanged(); + start(); + return true; +} + +bool SequencerIdentityProbe::setEndpoint(QUrl endpoint) +{ + Request updated = m_request; + updated.endpoint = std::move(endpoint); + if (!m_requestConfigured || !isValidRequest(updated)) + return false; + if (updated.endpoint == m_request.endpoint) + return true; + + m_request.endpoint = std::move(updated.endpoint); + restartContext(); + return true; +} + +void SequencerIdentityProbe::clearConfiguration() +{ + cancelPendingWork(); + m_requestConfigured = false; + m_request = {}; + m_networkConfiguration = {}; + m_context.clearConfiguration(); + emit snapshotChanged(); +} + +void SequencerIdentityProbe::setSequencerAvailable(bool available) +{ + if (m_sequencerAvailable == available) + return; + + cancelPendingWork(); + m_sequencerAvailable = available; + updateContextAvailability(); + emit snapshotChanged(); + start(); +} + +void SequencerIdentityProbe::setReachable(bool reachable) +{ + if (m_reachable == reachable) + return; + + cancelPendingWork(); + m_reachable = reachable; + updateContextAvailability(); + emit snapshotChanged(); + start(); +} + +void SequencerIdentityProbe::start() +{ + if (!m_requestConfigured + || !isValidEndpoint(m_request.endpoint) + || m_reply + || m_retryTimer->isActive()) + return; + + const std::optional contextGeneration = m_context.beginIdentityProbe(); + if (!contextGeneration) + return; + + emit snapshotChanged(); + QNetworkRequest request(m_request.endpoint); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/json")); + request.setTransferTimeout(REQUEST_TIMEOUT_MILLISECONDS); + QNetworkReply* reply = m_network->post(request, + jsonRpcBody(m_request.method, m_request.params)); + m_reply = reply; + const quint64 requestGeneration = m_requestGeneration; + connect(reply, &QNetworkReply::finished, this, + [this, reply, contextGeneration = *contextGeneration, requestGeneration]() { + handleReply(reply, contextGeneration, requestGeneration); + }); +} + +bool SequencerIdentityProbe::isValidRequest(const Request& request) +{ + return !request.method.trimmed().isEmpty() + && static_cast(request.identityFromResult); +} + +bool SequencerIdentityProbe::isValidEndpoint(const QUrl& endpoint) +{ + return endpoint.isValid() + && (endpoint.scheme() == QStringLiteral("http") + || endpoint.scheme() == QStringLiteral("https")) + && !endpoint.host().isEmpty(); +} + +QString SequencerIdentityProbe::stringIdentity(const QJsonValue& result) +{ + return result.isString() ? result.toString() : QString(); +} + +QString SequencerIdentityProbe::checkpointBlockHash(const QJsonValue& result) +{ + if (!result.isString()) + return {}; + + const QByteArray block = QByteArray::fromBase64(result.toString().toLatin1()); + if (block.size() < CHECKPOINT_BLOCK_HASH_OFFSET + CHECKPOINT_BLOCK_HASH_SIZE) + return {}; + return QString::fromLatin1( + block.mid(CHECKPOINT_BLOCK_HASH_OFFSET, CHECKPOINT_BLOCK_HASH_SIZE).toHex()); +} + +void SequencerIdentityProbe::restartContext() +{ + cancelPendingWork(); + if (!m_context.configure(m_networkConfiguration)) { + m_requestConfigured = false; + emit snapshotChanged(); + return; + } + updateContextAvailability(); + emit snapshotChanged(); + start(); +} + +void SequencerIdentityProbe::updateContextAvailability() +{ + m_context.setSequencerAvailable(m_sequencerAvailable + && isValidEndpoint(m_request.endpoint)); + m_context.setReachable(m_reachable); +} + +void SequencerIdentityProbe::cancelPendingWork() +{ + m_retryTimer->stop(); + m_nextRetryDelayMilliseconds = INITIAL_RETRY_DELAY_MILLISECONDS; + ++m_requestGeneration; + if (!m_reply) + return; + + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + reply->deleteLater(); +} + +void SequencerIdentityProbe::handleReply(QNetworkReply* reply, + quint64 contextGeneration, + quint64 requestGeneration) +{ + if (m_reply == reply) + m_reply = nullptr; + if (requestGeneration != m_requestGeneration) { + reply->deleteLater(); + return; + } + + QString failure; + QString identity; + const QVariant status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + if (status.isValid() && !hasSuccessStatus(status)) { + failure = QStringLiteral("http_status"); + } else if (reply->error() != QNetworkReply::NoError) { + failure = QStringLiteral("transport_error"); + } else if (!hasSuccessStatus(status)) { + failure = QStringLiteral("http_status"); + } else { + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(reply->readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + failure = QStringLiteral("malformed_response"); + } else { + const QJsonObject response = document.object(); + const QJsonValue rpcError = response.value(QStringLiteral("error")); + if ((!rpcError.isUndefined() && !rpcError.isNull())) { + failure = QStringLiteral("json_rpc_error"); + } else { + const QJsonValue result = response.value(QStringLiteral("result")); + if (result.isUndefined()) { + failure = QStringLiteral("malformed_response"); + } else { + identity = m_request.identityFromResult(result); + if (identity.isEmpty()) + failure = QStringLiteral("malformed_response"); + } + } + } + } + + const bool accepted = m_context.finishIdentityProbe(contextGeneration, identity); + reply->deleteLater(); + if (!accepted) + return; + + emit snapshotChanged(); + if (!failure.isEmpty()) { + emit probeFailed(failure); + scheduleRetry(); + } else if (!m_context.isReady() && m_context.needsIdentityProbe()) { + emit probeFailed(QStringLiteral("invalid_identity")); + scheduleRetry(); + } else if (m_context.isReady()) { + m_nextRetryDelayMilliseconds = INITIAL_RETRY_DELAY_MILLISECONDS; + } +} + +void SequencerIdentityProbe::scheduleRetry() +{ + if (!m_requestConfigured || !m_context.needsIdentityProbe() || m_retryTimer->isActive()) + return; + + const int delay = m_nextRetryDelayMilliseconds; + m_nextRetryDelayMilliseconds = std::min(m_nextRetryDelayMilliseconds * 2, + MAX_RETRY_DELAY_MILLISECONDS); + m_retryTimer->start(delay); +} diff --git a/apps/shared/wallet/src/SequencerIdentityProbe.h b/apps/shared/wallet/src/SequencerIdentityProbe.h new file mode 100644 index 00000000..10594003 --- /dev/null +++ b/apps/shared/wallet/src/SequencerIdentityProbe.h @@ -0,0 +1,91 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include "SequencerNetworkContext.h" + +class QNetworkAccessManager; +class QNetworkReply; +class QTimer; + +// Owns the JSON-RPC lifecycle used to prove that a wallet endpoint belongs to +// a configured network. Consumers supply only their RPC method, parameters, +// and the protocol-specific extraction of an identity from `result`. +class SequencerIdentityProbe final : public QObject { + Q_OBJECT + +public: + using IdentityParser = std::function; + + struct Request { + QUrl endpoint; + QString method; + QJsonArray params; + IdentityParser identityFromResult; + }; + + explicit SequencerIdentityProbe(QObject* parent = nullptr); + ~SequencerIdentityProbe() override; + + // Replaces both the expected network identity and RPC request. Existing + // replies are aborted before the new context can issue a probe. + bool configure(SequencerNetworkContext::Configuration network, Request request); + + // An endpoint change invalidates the previous identity result, including a + // reply that may already be in flight. An invalid/empty endpoint leaves the + // configured network in network_unknown until a valid endpoint is supplied. + bool setEndpoint(QUrl endpoint); + + void clearConfiguration(); + void setSequencerAvailable(bool available); + void setReachable(bool reachable); + + // Safe to call after every wallet/network state update. It starts a probe + // only when the context currently needs one and no retry is pending. + void start(); + + bool isConfigured() const { return m_context.isConfigured(); } + bool isReady() const { return m_context.isReady(); } + const SequencerNetworkSnapshot& snapshot() const { return m_context.snapshot(); } + + // Common JSON-RPC result parsers. `checkpointBlockHash` decodes the + // fixed-layout base64 block response used by checkpoint probes. + static QString stringIdentity(const QJsonValue& result); + static QString checkpointBlockHash(const QJsonValue& result); + +signals: + // Emitted whenever the externally visible network state changes. + void snapshotChanged(); + // A transient RPC failure was rejected and a retry may be scheduled. + void probeFailed(const QString& reason); + +private: + static bool isValidRequest(const Request& request); + static bool isValidEndpoint(const QUrl& endpoint); + + void restartContext(); + void updateContextAvailability(); + void cancelPendingWork(); + void handleReply(QNetworkReply* reply, quint64 contextGeneration, + quint64 requestGeneration); + void scheduleRetry(); + + SequencerNetworkContext m_context; + SequencerNetworkContext::Configuration m_networkConfiguration; + Request m_request; + QNetworkAccessManager* m_network; + QNetworkReply* m_reply = nullptr; + QTimer* m_retryTimer; + quint64 m_requestGeneration = 0; + int m_nextRetryDelayMilliseconds = 250; + bool m_requestConfigured = false; + bool m_sequencerAvailable = false; + bool m_reachable = false; +}; diff --git a/apps/shared/wallet/src/SequencerNetworkContext.cpp b/apps/shared/wallet/src/SequencerNetworkContext.cpp new file mode 100644 index 00000000..38fae63c --- /dev/null +++ b/apps/shared/wallet/src/SequencerNetworkContext.cpp @@ -0,0 +1,133 @@ +#include "SequencerNetworkContext.h" + +#include + +namespace { +bool isLowerHex(const QString& value, int size) +{ + if (value.size() != size) + return false; + for (const QChar character : value) { + const bool digit = character >= QLatin1Char('0') + && character <= QLatin1Char('9'); + if (!digit && (character < QLatin1Char('a') || character > QLatin1Char('f'))) + return false; + } + return true; +} +} + +bool SequencerNetworkContext::configure(Configuration configuration) +{ + clearConfiguration(); + m_snapshot.id = std::move(configuration.id); + if (!isValidIdentity(configuration.expectedIdentity)) + return false; + + m_expectedIdentity = std::move(configuration.expectedIdentity); + m_fingerprintPrefix = std::move(configuration.fingerprintPrefix); + m_configured = true; + clearIdentity(QStringLiteral("network_unknown")); + return true; +} + +void SequencerNetworkContext::clearConfiguration() +{ + invalidateProbe(); + m_snapshot = {}; + m_snapshot.status = QStringLiteral("config_missing"); + m_expectedIdentity.clear(); + m_fingerprintPrefix.clear(); + m_configured = false; + m_sequencerAvailable = false; + m_reachable = false; +} + +bool SequencerNetworkContext::needsIdentityProbe() const +{ + return m_configured + && m_sequencerAvailable + && m_reachable + && !m_probeInFlight + && (m_snapshot.status == QStringLiteral("loading") + || m_snapshot.status == QStringLiteral("network_unknown")); +} + +void SequencerNetworkContext::setSequencerAvailable(bool available) +{ + if (m_sequencerAvailable == available) + return; + + m_sequencerAvailable = available; + if (!m_configured) + return; + + invalidateProbe(); + clearIdentity(available && m_reachable ? QStringLiteral("loading") + : QStringLiteral("network_unknown")); +} + +void SequencerNetworkContext::setReachable(bool reachable) +{ + if (m_reachable == reachable) + return; + + m_reachable = reachable; + if (!m_configured) + return; + + invalidateProbe(); + clearIdentity(reachable && m_sequencerAvailable ? QStringLiteral("loading") + : QStringLiteral("network_unknown")); +} + +std::optional SequencerNetworkContext::beginIdentityProbe() +{ + if (!needsIdentityProbe()) + return std::nullopt; + + m_probeInFlight = true; + const quint64 generation = ++m_probeGeneration; + clearIdentity(QStringLiteral("loading")); + return generation; +} + +bool SequencerNetworkContext::finishIdentityProbe(quint64 generation, + const QString& identity) +{ + if (!m_configured + || !m_sequencerAvailable + || !m_reachable + || !m_probeInFlight + || generation != m_probeGeneration) { + return false; + } + + m_probeInFlight = false; + if (!isValidIdentity(identity)) { + clearIdentity(QStringLiteral("network_unknown")); + } else if (identity != m_expectedIdentity) { + clearIdentity(QStringLiteral("network_mismatch")); + } else { + m_snapshot.status = QStringLiteral("ready"); + m_snapshot.fingerprint = m_fingerprintPrefix + identity; + } + return true; +} + +bool SequencerNetworkContext::isValidIdentity(const QString& value) +{ + return isLowerHex(value, 64); +} + +void SequencerNetworkContext::clearIdentity(const QString& status) +{ + m_snapshot.status = status; + m_snapshot.fingerprint.clear(); +} + +void SequencerNetworkContext::invalidateProbe() +{ + ++m_probeGeneration; + m_probeInFlight = false; +} diff --git a/apps/shared/wallet/src/SequencerNetworkContext.h b/apps/shared/wallet/src/SequencerNetworkContext.h new file mode 100644 index 00000000..302a44a2 --- /dev/null +++ b/apps/shared/wallet/src/SequencerNetworkContext.h @@ -0,0 +1,60 @@ +#pragma once + +#include + +#include +#include + +// State shared by consumers that need to verify they are talking to a known +// sequencer. Deployment-specific configuration belongs to the consumer; this +// type only compares a supplied identity and tracks the probe lifecycle. +struct SequencerNetworkSnapshot { + QString id; + QString status = QStringLiteral("config_missing"); + QString fingerprint; +}; + +class SequencerNetworkContext final { +public: + struct Configuration { + QString id; + QString expectedIdentity; + QString fingerprintPrefix; + }; + + // Replaces the active network. Returns false and publishes config_missing + // when the expected identity is not a 64-character lowercase hex value. + bool configure(Configuration configuration); + void clearConfiguration(); + + bool isConfigured() const { return m_configured; } + bool isReady() const { return m_snapshot.status == QStringLiteral("ready"); } + bool needsIdentityProbe() const; + bool identityProbeInFlight() const { return m_probeInFlight; } + const SequencerNetworkSnapshot& snapshot() const { return m_snapshot; } + + // These inputs are intentionally separate: an endpoint can be configured + // while it is unreachable. Either loss invalidates an outstanding probe. + void setSequencerAvailable(bool available); + void setReachable(bool reachable); + + // A caller must retain this generation and pass it back when its async RPC + // completes. Empty means a probe cannot currently start. + std::optional beginIdentityProbe(); + bool finishIdentityProbe(quint64 generation, const QString& identity); + + static bool isValidIdentity(const QString& value); + +private: + void clearIdentity(const QString& status); + void invalidateProbe(); + + SequencerNetworkSnapshot m_snapshot; + QString m_expectedIdentity; + QString m_fingerprintPrefix; + quint64 m_probeGeneration = 0; + bool m_configured = false; + bool m_sequencerAvailable = false; + bool m_reachable = false; + bool m_probeInFlight = false; +}; diff --git a/apps/shared/wallet/src/SequencerNetworkSettings.cpp b/apps/shared/wallet/src/SequencerNetworkSettings.cpp new file mode 100644 index 00000000..ea5e9292 --- /dev/null +++ b/apps/shared/wallet/src/SequencerNetworkSettings.cpp @@ -0,0 +1,60 @@ +#include "SequencerNetworkSettings.h" + +#include +#include +#include +#include + +namespace { +std::optional settingsForIdentity( + const QString& id, + const QString& identity, + const QString& fingerprintPrefix, + SequencerIdentityMethod method) +{ + if (!SequencerNetworkContext::isValidIdentity(identity)) + return std::nullopt; + + SequencerNetworkSettings settings; + settings.context = { id, identity, fingerprintPrefix }; + settings.identityMethod = method; + return settings; +} +} + +std::optional SequencerNetworkSettingsLoader::load( + const QString& networkId, + const QString& devnetConfigPath, + const QString& resourcePath) +{ + Q_INIT_RESOURCE(logos_wallet_access_network_data); + + const QString id = networkId.trimmed().isEmpty() + ? QStringLiteral("testnet") : networkId.trimmed(); + if (id == QStringLiteral("devnet")) { + QFile file(devnetConfigPath); + if (devnetConfigPath.isEmpty() || !file.open(QIODevice::ReadOnly)) + return std::nullopt; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return std::nullopt; + return settingsForIdentity( + id, + document.object().value(QStringLiteral("channelId")).toString(), + QStringLiteral("channel:"), + SequencerIdentityMethod::ChannelId); + } + + QFile file(resourcePath); + if (!file.open(QIODevice::ReadOnly)) + return std::nullopt; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return std::nullopt; + const QJsonObject entry = document.object().value(id).toObject(); + return settingsForIdentity( + id, + entry.value(QStringLiteral("checkpointHash")).toString(), + QStringLiteral("block10:"), + SequencerIdentityMethod::CheckpointBlock); +} diff --git a/apps/shared/wallet/src/SequencerNetworkSettings.h b/apps/shared/wallet/src/SequencerNetworkSettings.h new file mode 100644 index 00000000..20983a67 --- /dev/null +++ b/apps/shared/wallet/src/SequencerNetworkSettings.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include + +#include "SequencerNetworkContext.h" + +enum class SequencerIdentityMethod { + CheckpointBlock, + ChannelId, +}; + +struct SequencerNetworkSettings { + SequencerNetworkContext::Configuration context; + SequencerIdentityMethod identityMethod = SequencerIdentityMethod::CheckpointBlock; +}; + +// Loads the identity contract for a wallet network. Program deployments and +// application-specific assets deliberately stay outside this loader. +class SequencerNetworkSettingsLoader final { +public: + static std::optional load( + const QString& networkId, + const QString& devnetConfigPath, + const QString& resourcePath = QStringLiteral(":/wallet/config/networks.json")); +}; diff --git a/apps/amm/src/TokenDefinitionCache.cpp b/apps/shared/wallet/src/TokenDefinitionCache.cpp similarity index 100% rename from apps/amm/src/TokenDefinitionCache.cpp rename to apps/shared/wallet/src/TokenDefinitionCache.cpp diff --git a/apps/amm/src/TokenDefinitionCache.h b/apps/shared/wallet/src/TokenDefinitionCache.h similarity index 100% rename from apps/amm/src/TokenDefinitionCache.h rename to apps/shared/wallet/src/TokenDefinitionCache.h diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp index 8b93327a..ebfac3fa 100644 --- a/apps/shared/wallet/src/WalletAccountModel.cpp +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -59,6 +59,8 @@ QVariant WalletAccountModel::data(const QModelIndex& index, int role) const return account.definitionId; case AliasRole: return account.alias; + case DecodedDataRole: + return account.decodedData; default: return {}; } @@ -84,6 +86,7 @@ QHash WalletAccountModel::roleNames() const { DefinitionIdRole, "definitionId" }, { AliasRole, "alias" }, { DisplayAddressRole, "displayAddress" }, + { DecodedDataRole, "decodedData" }, }; } @@ -130,6 +133,9 @@ void WalletAccountModel::replaceAccounts(const QVector& accounts, bool WalletAccountModel::applyPresentations( const QVector& presentations) { + if (presentations.isEmpty()) + return clearPresentations(); + QHash rowsByAddress; rowsByAddress.reserve(m_accounts.size()); for (int row = 0; row < m_accounts.size(); ++row) { @@ -154,6 +160,7 @@ bool WalletAccountModel::applyPresentations( entry.programName = presentation.programName; entry.accountType = presentation.accountType; entry.definitionId = presentation.definitionId; + entry.decodedData = presentation.decodedData; entry.semanticName = presentation.semanticName; entry.section = sectionFor(entry, presentation.hiddenFromAccounts); entry.canBePrimary = entry.kind == QStringLiteral("user") @@ -175,6 +182,7 @@ bool WalletAccountModel::applyPresentations( && entry.programName == current.programName && entry.accountType == current.accountType && entry.definitionId == current.definitionId + && entry.decodedData == current.decodedData && entry.canBePrimary == current.canBePrimary && entry.isPrimary == current.isPrimary) { continue; @@ -193,6 +201,48 @@ bool WalletAccountModel::applyPresentations( SectionRole, ProgramNameRole, AccountTypeRole, + DecodedDataRole, + CanBePrimaryRole, + IsPrimaryRole, + DefinitionIdRole, + }); + return true; +} + +bool WalletAccountModel::clearPresentations() +{ + int firstChanged = m_accounts.size(); + int lastChanged = -1; + for (int row = 0; row < m_accounts.size(); ++row) { + Entry& entry = m_accounts[row]; + const Entry current = entry; + resetPresentation(entry); + if (entry.alias == current.alias + && entry.semanticName == current.semanticName + && entry.name == current.name + && entry.kind == current.kind + && entry.section == current.section + && entry.programName == current.programName + && entry.accountType == current.accountType + && entry.definitionId == current.definitionId + && entry.decodedData == current.decodedData + && entry.canBePrimary == current.canBePrimary + && entry.isPrimary == current.isPrimary) { + continue; + } + if (row < firstChanged) + firstChanged = row; + lastChanged = row; + } + if (lastChanged < 0) + return false; + emit dataChanged(index(firstChanged), index(lastChanged), { + NameRole, + KindRole, + SectionRole, + ProgramNameRole, + AccountTypeRole, + DecodedDataRole, CanBePrimaryRole, IsPrimaryRole, DefinitionIdRole, @@ -280,6 +330,32 @@ QString WalletAccountModel::sectionFor(const Entry& entry, bool hiddenFromAccoun return QStringLiteral("advanced"); } +void WalletAccountModel::resetPresentation(Entry& entry) +{ + entry.semanticName.clear(); + entry.programName.clear(); + entry.accountType.clear(); + entry.definitionId.clear(); + entry.decodedData.clear(); + if (!entry.isPublic) { + entry.kind = QStringLiteral("private"); + entry.canBePrimary = true; + } else if (entry.readStatus != QStringLiteral("ok")) { + entry.kind = QStringLiteral("unknown"); + entry.canBePrimary = false; + } else if (entry.programOwner == DEFAULT_PROGRAM_OWNER) { + entry.kind = QStringLiteral("user"); + entry.canBePrimary = true; + } else { + entry.kind = QStringLiteral("program"); + entry.canBePrimary = false; + } + if (!entry.canBePrimary) + entry.isPrimary = false; + entry.section = sectionFor(entry); + updateEntryName(entry); +} + void WalletAccountModel::updateEntryName(Entry& entry) { entry.name = !entry.alias.isEmpty() diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h index 8e21a585..21351c11 100644 --- a/apps/shared/wallet/src/WalletAccountModel.h +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -14,6 +14,7 @@ struct WalletAccountPresentation { QString accountType; QString definitionId; bool hiddenFromAccounts = false; + QString decodedData; }; class WalletAccountModel final : public QAbstractListModel { @@ -39,6 +40,7 @@ class WalletAccountModel final : public QAbstractListModel { DefinitionIdRole, AliasRole, DisplayAddressRole, + DecodedDataRole, }; Q_ENUM(Role) @@ -52,6 +54,7 @@ class WalletAccountModel final : public QAbstractListModel { const QHash& aliases = {}, const QString& primaryAddress = {}); bool applyPresentations(const QVector& presentations); + bool clearPresentations(); void setAlias(const QString& address, const QString& alias); void setPrimaryAddress(const QString& address); bool contains(const QString& address) const; @@ -79,12 +82,14 @@ class WalletAccountModel final : public QAbstractListModel { QString programName; QString accountType; QString definitionId; + QString decodedData; bool canBePrimary = false; bool isPrimary = false; }; static QString defaultName(const Entry& entry); static QString sectionFor(const Entry& entry, bool hiddenFromAccounts = false); + void resetPresentation(Entry& entry); void updateEntryName(Entry& entry); QVector m_accounts; diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp index 9a64970d..cce6ae1f 100644 --- a/apps/shared/wallet/src/WalletController.cpp +++ b/apps/shared/wallet/src/WalletController.cpp @@ -360,6 +360,26 @@ void WalletController::applyAccountPresentations( } } +void WalletController::clearAccountPresentations() +{ + if (!m_accountModel->clearPresentations()) + return; + + const QString previousPrimary = m_state.primaryAccountAddress; + const QString previousPrimaryName = m_state.primaryAccountName; + QString primary = m_state.primaryAccountAddress; + if (!m_accountModel->canBePrimary(primary)) + primary = m_accountModel->firstAutomaticPrimary(); + m_accountModel->setPrimaryAddress(primary); + if (primary != previousPrimary) + storePrimaryAccount(primary); + updatePrimaryState(primary); + if (m_state.primaryAccountAddress != previousPrimary + || m_state.primaryAccountName != previousPrimaryName) { + emit stateChanged(); + } +} + QString WalletController::createAccount(bool isPublic) { const WalletAccountCreation creation = m_wallet.createAccount(isPublic); diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h index e30f733e..9dbfd85e 100644 --- a/apps/shared/wallet/src/WalletController.h +++ b/apps/shared/wallet/src/WalletController.h @@ -63,6 +63,7 @@ class WalletController final : public QObject { bool setPrimaryAccount(const QString& address); void applyAccountPresentations( const QVector& presentations); + void clearAccountPresentations(); signals: void stateChanged(); diff --git a/apps/amm/src/WalletIdlDecoder.cpp b/apps/shared/wallet/src/WalletIdlDecoder.cpp similarity index 97% rename from apps/amm/src/WalletIdlDecoder.cpp rename to apps/shared/wallet/src/WalletIdlDecoder.cpp index 986046de..20c82c59 100644 --- a/apps/amm/src/WalletIdlDecoder.cpp +++ b/apps/shared/wallet/src/WalletIdlDecoder.cpp @@ -7,7 +7,10 @@ #include #include -#include +extern "C" { +char* wallet_idl_decode_accounts(const char* requestJson); +void wallet_idl_decoder_free(char* value); +} WalletDecodeResult WalletIdlDecoder::decode( const QByteArray& idlJson, diff --git a/apps/amm/src/WalletIdlDecoder.h b/apps/shared/wallet/src/WalletIdlDecoder.h similarity index 100% rename from apps/amm/src/WalletIdlDecoder.h rename to apps/shared/wallet/src/WalletIdlDecoder.h diff --git a/apps/shared/wallet/src/WalletPortfolioService.cpp b/apps/shared/wallet/src/WalletPortfolioService.cpp new file mode 100644 index 00000000..cd753dc9 --- /dev/null +++ b/apps/shared/wallet/src/WalletPortfolioService.cpp @@ -0,0 +1,536 @@ +#include "WalletPortfolioService.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "TokenDefinitionCache.h" +#include "WalletAccountId.h" + +namespace { +const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0')); + +QJsonObject enumFields(const QJsonValue& value, const QString& variant) +{ + return value.toObject().value(variant).toObject(); +} + +QString decodedDataText(const QJsonValue& value) +{ + if (value.isObject()) { + return QString::fromUtf8( + QJsonDocument(value.toObject()).toJson(QJsonDocument::Indented)).trimmed(); + } + if (value.isArray()) { + return QString::fromUtf8( + QJsonDocument(value.toArray()).toJson(QJsonDocument::Indented)).trimmed(); + } + return {}; +} + +QString decimalAdd(const QString& left, const QString& right) +{ + if (left.isEmpty() || right.isEmpty()) + return {}; + if (!std::all_of(left.cbegin(), left.cend(), [](QChar value) { return value.isDigit(); }) + || !std::all_of(right.cbegin(), right.cend(), [](QChar value) { return value.isDigit(); })) { + return {}; + } + + QString result; + result.reserve(std::max(left.size(), right.size()) + 1); + qsizetype leftIndex = left.size(); + qsizetype rightIndex = right.size(); + int carry = 0; + while (leftIndex > 0 || rightIndex > 0 || carry > 0) { + const int leftDigit = leftIndex > 0 ? left.at(--leftIndex).digitValue() : 0; + const int rightDigit = rightIndex > 0 ? right.at(--rightIndex).digitValue() : 0; + const int sum = leftDigit + rightDigit + carry; + result.prepend(QChar(QLatin1Char('0').unicode() + sum % 10)); + carry = sum / 10; + } + while (result.size() > 1 && result.startsWith(QLatin1Char('0'))) + result.remove(0, 1); + return result; +} + +void addField(QCryptographicHash& hash, const QString& value) +{ + const QByteArray utf8 = value.toUtf8(); + hash.addData(QByteArray::number(utf8.size())); + hash.addData(QByteArrayLiteral(":")); + hash.addData(utf8); + hash.addData(QByteArrayLiteral(";")); +} + +QByteArray accountReadsSignature(const QVector& reads) +{ + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(QByteArray::number(reads.size())); + hash.addData(QByteArrayLiteral(";")); + for (const WalletAccountRead& read : reads) { + addField(hash, read.accountId); + addField(hash, read.status); + addField(hash, read.programOwner); + addField(hash, read.balanceHex); + addField(hash, read.nonceHex); + addField(hash, read.dataHex); + } + return hash.result(); +} + +bool matchingDefinitionReads(const TokenDefinitionCacheKey& key, + const QVector& reads) +{ + if (reads.size() != key.tokenIds.size()) + return false; + for (qsizetype index = 0; index < reads.size(); ++index) { + if (reads.at(index).accountId != key.tokenIds.at(index)) + return false; + } + return true; +} +} + +struct WalletPortfolioService::State { + struct Program { + QString name; + QByteArray idl; + }; + + struct Token { + QString id; + QString name; + QString programOwner; + QString status; + }; + + struct DefinitionResolution { + TokenDefinitionCacheKey key; + QByteArray idl; + QByteArray readsSignature; + QVector tokens; + QString tokenProgramId; + QString error; + }; + + struct DecodeCache { + QByteArray idl; + QByteArray readsSignature; + WalletDecodeResult result; + }; + + State(WalletProvider& provider, Decoder decoderFunction) + : definitionCache(provider), + decoder(decoderFunction ? std::move(decoderFunction) + : Decoder(WalletIdlDecoder::decode)) + { + } + + WalletDecodeResult decode(const QString& programId, + const Program& program, + const QVector& reads) + { + const QByteArray signature = accountReadsSignature(reads); + const auto cached = decodedPrograms.constFind(programId); + if (cached != decodedPrograms.cend() + && cached->idl == program.idl + && cached->readsSignature == signature) { + return cached->result; + } + + WalletDecodeResult result = decoder(program.idl, reads); + decodedPrograms.insert(programId, { program.idl, signature, result }); + return result; + } + + static DefinitionResolution unavailableDefinitions( + const WalletPortfolioRequest& request, + const TokenDefinitionCacheKey& key, + const QString& error); + static DefinitionResolution decodeDefinitions( + State& state, + const WalletPortfolioRequest& request, + const TokenDefinitionCacheKey& key, + const QVector& reads); + static WalletPortfolioResult buildPortfolio( + State& state, + const WalletPortfolioRequest& request, + const DefinitionResolution& definitions); + + TokenDefinitionCache definitionCache; + Decoder decoder; + QHash programs; + QHash decodedPrograms; + std::optional definitions; + quint64 generation = 0; +}; + +namespace { +WalletPortfolioResult failureResult(const QString& status, const QString& error) +{ + WalletPortfolioResult result; + result.status = status; + result.error = error; + return result; +} +} + +WalletPortfolioService::State::DefinitionResolution +WalletPortfolioService::State::unavailableDefinitions( + const WalletPortfolioRequest& request, + const TokenDefinitionCacheKey& key, + const QString& error) +{ + WalletPortfolioService::State::DefinitionResolution resolution; + resolution.key = key; + resolution.idl = request.tokenIdl; + resolution.error = error; + resolution.tokens.reserve(request.tokenDefinitionIds.size()); + for (const QString& id : request.tokenDefinitionIds) { + resolution.tokens.append({ + id, + QStringLiteral("Unknown token"), + {}, + QStringLiteral("unavailable"), + }); + } + return resolution; +} + +WalletPortfolioService::State::DefinitionResolution +WalletPortfolioService::State::decodeDefinitions( + State& state, + const WalletPortfolioRequest& request, + const TokenDefinitionCacheKey& key, + const QVector& reads) +{ + const QByteArray signature = accountReadsSignature(reads); + if (state.definitions + && state.definitions->key == key + && state.definitions->idl == request.tokenIdl + && state.definitions->readsSignature == signature) { + return *state.definitions; + } + + if (!matchingDefinitionReads(key, reads)) { + auto resolution = unavailableDefinitions( + request, key, QStringLiteral("definition_read_failed")); + resolution.readsSignature = signature; + state.definitions = resolution; + return resolution; + } + + const WalletDecodeResult decoded = state.decoder(request.tokenIdl, reads); + if (!decoded.ok() || decoded.accounts.size() != reads.size()) { + auto resolution = unavailableDefinitions( + request, + key, + decoded.error.isEmpty() ? QStringLiteral("definition_decode_failed") + : decoded.error); + resolution.readsSignature = signature; + state.definitions = resolution; + return resolution; + } + + WalletPortfolioService::State::DefinitionResolution resolution; + resolution.key = key; + resolution.idl = request.tokenIdl; + resolution.readsSignature = signature; + resolution.tokens.reserve(reads.size()); + for (qsizetype index = 0; index < reads.size(); ++index) { + const WalletAccountRead& read = reads.at(index); + const WalletDecodedAccount& account = decoded.accounts.at(index); + WalletPortfolioService::State::Token token { + request.tokenDefinitionIds.at(index), + QStringLiteral("Unknown token"), + {}, + QStringLiteral("unavailable"), + }; + const QJsonObject fungible = enumFields(account.value, QStringLiteral("Fungible")); + if (read.ok() + && account.id == read.accountId + && account.status == QStringLiteral("decoded") + && account.typeName == QStringLiteral("TokenDefinition") + && !fungible.isEmpty() + && read.programOwner != DEFAULT_PROGRAM_OWNER) { + token.name = fungible.value(QStringLiteral("name")).toString().trimmed(); + if (token.name.isEmpty()) + token.name = QStringLiteral("Unnamed token"); + token.programOwner = read.programOwner; + token.status = QStringLiteral("ready"); + if (resolution.tokenProgramId.isEmpty()) { + resolution.tokenProgramId = read.programOwner; + } else if (resolution.tokenProgramId != read.programOwner) { + resolution.error = QStringLiteral("token_program_mismatch"); + } + } + resolution.tokens.append(std::move(token)); + } + + if (resolution.tokenProgramId.isEmpty() && resolution.error.isEmpty()) + resolution.error = QStringLiteral("definitions_unavailable"); + state.definitions = resolution; + return resolution; +} + +WalletPortfolioResult WalletPortfolioService::State::buildPortfolio( + State& state, + const WalletPortfolioRequest& request, + const WalletPortfolioService::State::DefinitionResolution& definitions) +{ + WalletPortfolioResult result; + QHash programs = state.programs; + if (!definitions.tokenProgramId.isEmpty()) { + programs.insert(definitions.tokenProgramId, { + request.tokenProgramName.isEmpty() ? QStringLiteral("Token") + : request.tokenProgramName, + request.tokenIdl, + }); + } + + QHash balances; + bool tokenHoldingFailure = false; + bool unreadPublicAccount = false; + bool programFailure = false; + const QVector& reads = request.publicAccountReads; + for (auto iterator = programs.cbegin(); iterator != programs.cend(); ++iterator) { + QVector programReads; + for (const WalletAccountRead& read : reads) { + if (!read.ok()) { + // A failed read has no trustworthy owner. It could be a token + // holding, so reporting a zero balance would be misleading. + unreadPublicAccount = true; + continue; + } + if (read.programOwner != iterator.key()) + continue; + programReads.append(read); + } + if (programReads.isEmpty()) + continue; + + const WalletDecodeResult decoded = state.decode(iterator.key(), iterator.value(), programReads); + if (!decoded.ok()) { + programFailure = true; + if (iterator.key() == definitions.tokenProgramId) + tokenHoldingFailure = true; + continue; + } + if (decoded.accounts.size() != programReads.size()) { + programFailure = true; + if (iterator.key() == definitions.tokenProgramId) + tokenHoldingFailure = true; + } + + for (const WalletDecodedAccount& account : decoded.accounts) { + WalletAccountPresentation presentation; + presentation.address = account.id; + presentation.programName = iterator.value().name; + presentation.accountType = account.typeName; + if (account.status == QStringLiteral("decoded")) + presentation.decodedData = decodedDataText(account.value); + + if (iterator.key() == definitions.tokenProgramId + && account.typeName == QStringLiteral("TokenHolding")) { + const QJsonObject fungible = enumFields(account.value, QStringLiteral("Fungible")); + const QString encodedDefinitionId = fungible.value( + QStringLiteral("definition_id")).toString(); + const QString definitionId = account.accountIds.value(encodedDefinitionId); + const QString amount = fungible.value(QStringLiteral("balance")).toString(); + const QString total = decimalAdd(balances.value(definitionId, QStringLiteral("0")), amount); + if (account.status != QStringLiteral("decoded") + || fungible.isEmpty() + || definitionId.isEmpty() + || total.isEmpty()) { + tokenHoldingFailure = true; + } else { + balances.insert(definitionId, total); + } + presentation.kind = QStringLiteral("token_holding"); + presentation.definitionId = definitionId; + presentation.hiddenFromAccounts = true; + for (const auto& token : definitions.tokens) { + if (token.id == definitionId) { + presentation.semanticName = token.name + QStringLiteral(" holding"); + break; + } + } + } else if (iterator.key() == definitions.tokenProgramId + && account.typeName == QStringLiteral("TokenDefinition")) { + presentation.kind = QStringLiteral("token_definition"); + presentation.semanticName = enumFields( + account.value, QStringLiteral("Fungible")) + .value(QStringLiteral("name")).toString(); + } else if (iterator.key() == definitions.tokenProgramId + && account.typeName == QStringLiteral("TokenMetadata")) { + presentation.kind = QStringLiteral("token_metadata"); + } else { + presentation.kind = QStringLiteral("program"); + presentation.semanticName = account.typeName; + } + result.presentations.append(std::move(presentation)); + } + } + + int unavailableDefinitions = 0; + QVariantList available; + for (const auto& token : definitions.tokens) { + const bool unavailable = token.status != QStringLiteral("ready") + || tokenHoldingFailure + || unreadPublicAccount + || !definitions.error.isEmpty(); + const QString balance = unavailable + ? QString() + : balances.value(token.id, QStringLiteral("0")); + const bool positive = balance != QStringLiteral("0") && !balance.isEmpty(); + QString displayDefinitionId = walletAccountIdToBase58(token.id); + if (displayDefinitionId.isEmpty()) + displayDefinitionId = token.id; + QVariantMap asset { + { QStringLiteral("name"), token.name }, + { QStringLiteral("symbol"), token.name }, + { QStringLiteral("balance"), balance }, + { QStringLiteral("definitionId"), token.id }, + { QStringLiteral("displayDefinitionId"), displayDefinitionId }, + { QStringLiteral("programOwner"), token.programOwner }, + { QStringLiteral("status"), unavailable ? QStringLiteral("unavailable") + : QStringLiteral("ready") }, + { QStringLiteral("section"), positive ? QStringLiteral("assets") + : QStringLiteral("available") }, + }; + if (positive) + result.assets.append(std::move(asset)); + else + available.append(std::move(asset)); + if (unavailable) + ++unavailableDefinitions; + } + result.assets.append(available); + + if (definitions.tokenProgramId.isEmpty()) { + result.status = QStringLiteral("error"); + result.error = definitions.error.isEmpty() + ? QStringLiteral("definitions_unavailable") : definitions.error; + } else if (!definitions.error.isEmpty()) { + result.status = QStringLiteral("error"); + result.error = definitions.error; + } else if (unavailableDefinitions > 0 || tokenHoldingFailure || unreadPublicAccount) { + result.status = QStringLiteral("partial"); + result.error = tokenHoldingFailure + ? QStringLiteral("holding_decode_failed") + : unreadPublicAccount + ? QStringLiteral("public_account_read_failed") + : QStringLiteral("some_definitions_unavailable"); + } else if (programFailure) { + result.status = QStringLiteral("partial"); + result.error = QStringLiteral("program_decode_failed"); + } else { + result.status = QStringLiteral("ready"); + } + return result; +} +WalletPortfolioService::WalletPortfolioService(WalletProvider& provider, Decoder decoder) + : m_state(std::make_shared(provider, std::move(decoder))) +{ +} + +WalletPortfolioService::~WalletPortfolioService() +{ + cancel(); + m_state.reset(); +} + +void WalletPortfolioService::registerProgram(const QString& programId, + const QString& programName, + const QByteArray& idlJson) +{ + if (!m_state || programId.isEmpty() || programName.isEmpty() || idlJson.isEmpty()) + return; + const auto existing = m_state->programs.constFind(programId); + if (existing != m_state->programs.cend() + && existing->name == programName + && existing->idl == idlJson) { + return; + } + m_state->programs.insert(programId, { programName, idlJson }); + m_state->decodedPrograms.remove(programId); +} + +void WalletPortfolioService::unregisterProgram(const QString& programId) +{ + if (!m_state) + return; + m_state->programs.remove(programId); + m_state->decodedPrograms.remove(programId); +} + +void WalletPortfolioService::refresh(WalletPortfolioRequest request, Callback callback) +{ + if (!m_state || !callback) + return; + + const std::shared_ptr state = m_state; + const quint64 generation = ++state->generation; + if (request.walletFailure != WalletFailure::None) { + callback(failureResult(QStringLiteral("error"), walletFailureCode(request.walletFailure))); + return; + } + if (request.networkId.isEmpty() + || request.networkFingerprint.isEmpty() + || request.sequencerAddress.isEmpty() + || request.tokenDefinitionIds.isEmpty()) { + callback(failureResult(QStringLiteral("blocked"), QStringLiteral("network_context_missing"))); + return; + } + if (request.tokenIdl.isEmpty()) { + callback(failureResult(QStringLiteral("error"), QStringLiteral("token_idl_missing"))); + return; + } + + const TokenDefinitionCacheKey key { + request.networkId, + request.networkFingerprint, + request.sequencerAddress, + request.tokenDefinitionIds, + }; + const std::weak_ptr weakState = state; + state->definitionCache.read( + key, + [weakState, generation, request = std::move(request), key, callback = std::move(callback)]( + QVector reads) mutable { + const std::shared_ptr state = weakState.lock(); + if (!state || generation != state->generation) + return; + const State::DefinitionResolution definitions = State::decodeDefinitions( + *state, request, key, reads); + if (generation != state->generation) + return; + callback(State::buildPortfolio(*state, request, definitions)); + }); +} + +void WalletPortfolioService::cancel() +{ + if (!m_state) + return; + ++m_state->generation; + m_state->definitionCache.cancelPending(); +} + +void WalletPortfolioService::clear() +{ + if (!m_state) + return; + ++m_state->generation; + m_state->definitionCache.clear(); + m_state->definitions.reset(); + m_state->decodedPrograms.clear(); +} diff --git a/apps/shared/wallet/src/WalletPortfolioService.h b/apps/shared/wallet/src/WalletPortfolioService.h new file mode 100644 index 00000000..ea2d467e --- /dev/null +++ b/apps/shared/wallet/src/WalletPortfolioService.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include "WalletAccountModel.h" +#include "WalletIdlDecoder.h" +#include "WalletProvider.h" + +// Input for a portfolio refresh. The snapshot constructor deliberately copies +// only `WalletSnapshot::publicAccountReads`; callers must not reconstruct reads +// from the display-model accounts. +struct WalletPortfolioRequest { + WalletPortfolioRequest() = default; + explicit WalletPortfolioRequest(const WalletSnapshot& snapshot) + : walletFailure(snapshot.failure), + sequencerAddress(snapshot.sequencerAddress), + publicAccountReads(snapshot.publicAccountReads) + { + } + + WalletFailure walletFailure = WalletFailure::None; + QString sequencerAddress; + QVector publicAccountReads; + QString networkId; + QString networkFingerprint; + QStringList tokenDefinitionIds; + QByteArray tokenIdl; + QString tokenProgramName = QStringLiteral("Token"); +}; + +struct WalletPortfolioResult { + QVector presentations; + QVariantList assets; + QString status = QStringLiteral("idle"); + QString error; +}; + +// Resolves configured token definitions and presents accounts owned by +// registered IDL programs. The service owns asynchronous definition reads and +// is safe to destroy while a provider callback is outstanding. +class WalletPortfolioService final { +public: + using Callback = std::function; + using Decoder = std::function&)>; + + explicit WalletPortfolioService(WalletProvider& provider, + Decoder decoder = {}); + ~WalletPortfolioService(); + + WalletPortfolioService(const WalletPortfolioService&) = delete; + WalletPortfolioService& operator=(const WalletPortfolioService&) = delete; + + // Adds an IDL-backed account presentation. Later registrations for the + // same program id replace the prior definition. + void registerProgram(const QString& programId, + const QString& programName, + const QByteArray& idlJson); + void unregisterProgram(const QString& programId); + + // Emits one final result. Callers publish their loading state before + // calling refresh; a cache hit may invoke `callback` synchronously. + void refresh(WalletPortfolioRequest request, Callback callback); + + // Drops callbacks from in-flight work without discarding reusable data. + void cancel(); + // Drops all network- and data-dependent cached state. + void clear(); + +private: + struct State; + + std::shared_ptr m_state; +}; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index 43d839f0..69a1f888 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -97,6 +97,7 @@ private slots: void staleAsyncMutationCannotCrossSession(); void destroyedProviderIgnoresLateMutation(); void exposesStableAccountModelRoles(); + void clearsStaleAccountPresentationsWithoutInvalidatingPrimary(); void encodesAccountIdsForDisplay(); void persistsHumanizedWalletPreferences(); void fakeProviderImplementsConsumerContract(); @@ -604,6 +605,8 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() 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(), walletAccountIdToBase58(ACCOUNT_B)); QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(), @@ -623,6 +626,7 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() QStringLiteral("UserAccount"), {}, false, + QStringLiteral("{\"public_key\":\"Public/test\"}"), }, { walletAccountIdToBase58(ACCOUNT_C), @@ -640,6 +644,8 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() 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")); @@ -652,6 +658,72 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() 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({ + { + walletAccountIdToBase58(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.clearAccountPresentations(); + + 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::encodesAccountIdsForDisplay() { QCOMPARE(walletAccountIdToBase58( diff --git a/apps/shared/wallet/tests/cpp/SequencerIdentityProbeTest.cpp b/apps/shared/wallet/tests/cpp/SequencerIdentityProbeTest.cpp new file mode 100644 index 00000000..87747215 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/SequencerIdentityProbeTest.cpp @@ -0,0 +1,329 @@ +#include "SequencerIdentityProbe.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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 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 m_partialRequests; + QQueue m_responses; + QQueue> m_held; + QList 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("status"); + QTest::addColumn("body"); + QTest::addColumn("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")); + QVERIFY(!probe.isReady()); +} + +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(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" diff --git a/apps/shared/wallet/tests/cpp/SequencerNetworkContextTest.cpp b/apps/shared/wallet/tests/cpp/SequencerNetworkContextTest.cpp new file mode 100644 index 00000000..7c9a98e1 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/SequencerNetworkContextTest.cpp @@ -0,0 +1,96 @@ +#include + +#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 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 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 firstProbe = context.beginIdentityProbe(); + QVERIFY(firstProbe.has_value()); + + context.setReachable(false); + context.setReachable(true); + const std::optional 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" diff --git a/apps/shared/wallet/tests/cpp/SequencerNetworkSettingsTest.cpp b/apps/shared/wallet/tests/cpp/SequencerNetworkSettingsTest.cpp new file mode 100644 index 00000000..e018d2c8 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/SequencerNetworkSettingsTest.cpp @@ -0,0 +1,66 @@ +#include "SequencerNetworkSettings.h" + +#include +#include +#include +#include + +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" diff --git a/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp b/apps/shared/wallet/tests/cpp/TokenDefinitionCacheTest.cpp similarity index 100% rename from apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp rename to apps/shared/wallet/tests/cpp/TokenDefinitionCacheTest.cpp diff --git a/apps/shared/wallet/tests/cpp/WalletIdlDecoderLinkTest.cpp b/apps/shared/wallet/tests/cpp/WalletIdlDecoderLinkTest.cpp new file mode 100644 index 00000000..d9085d2d --- /dev/null +++ b/apps/shared/wallet/tests/cpp/WalletIdlDecoderLinkTest.cpp @@ -0,0 +1,22 @@ +#include "WalletIdlDecoder.h" + +#include + +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" diff --git a/apps/shared/wallet/tests/cpp/WalletPortfolioServiceTest.cpp b/apps/shared/wallet/tests/cpp/WalletPortfolioServiceTest.cpp new file mode 100644 index 00000000..52412831 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/WalletPortfolioServiceTest.cpp @@ -0,0 +1,259 @@ +#include "FakeWalletProvider.h" +#include "WalletPortfolioService.h" + +#include + +#include +#include + +// 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&) +{ + return { + QStringLiteral("error"), + QStringLiteral("unexpected_default_decoder"), + {}, + }; +} + +namespace { +const QString DEFINITION_ID(64, QLatin1Char('a')); +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() +{ + WalletSnapshot snapshot; + snapshot.sequencerAddress = QStringLiteral("http://127.0.0.1:8080"); + // Deliberately omit `snapshot.accounts`: the service must use the cached + // public reads supplied by the wallet provider, not rebuild them from UI rows. + 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.networkId = QStringLiteral("devnet"); + result.networkFingerprint = QStringLiteral("channel:one"); + result.tokenDefinitionIds = { DEFINITION_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& 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 reusesDefinitionReadsAndUnchangedDecodes(); + void exposesHoldingDecodeFailureWithoutZeroBalance(); + void exposesUnreadPublicAccountWithoutZeroBalance(); + void onlyDeliversLatestRefresh(); + void dropsCallbackAfterServiceDestruction(); +}; + +void WalletPortfolioServiceTest::reusesDefinitionReadsAndUnchangedDecodes() +{ + FakeWalletProvider provider; + provider.readResults = { read(DEFINITION_ID, TOKEN_PROGRAM_ID, QStringLiteral("definition")) }; + int decodeCalls = 0; + WalletPortfolioService service(provider, decoder(&decodeCalls)); + service.registerProgram(AMM_PROGRAM_ID, QStringLiteral("AMM"), QByteArrayLiteral("amm-idl")); + + WalletPortfolioResult first; + service.refresh(request(), [&first](WalletPortfolioResult result) { + first = std::move(result); + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + QCOMPARE(first.status, QStringLiteral("ready")); + QCOMPARE(first.assets.size(), 1); + const QVariantMap asset = first.assets.first().toMap(); + QCOMPARE(asset.value(QStringLiteral("balance")).toString(), QStringLiteral("25")); + QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("ready")); + QCOMPARE(first.presentations.size(), 3); + const int firstDecodeCalls = decodeCalls; + QVERIFY(firstDecodeCalls > 0); + + // AMM composition registers its unchanged IDL on every refresh. That must + // preserve the decoded-account cache instead of re-entering the FFI. + service.registerProgram(AMM_PROGRAM_ID, QStringLiteral("AMM"), QByteArrayLiteral("amm-idl")); + WalletPortfolioResult second; + service.refresh(request(), [&second](WalletPortfolioResult result) { + second = std::move(result); + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + QCOMPARE(decodeCalls, firstDecodeCalls); + QCOMPARE(second.status, QStringLiteral("ready")); + QCOMPARE(second.assets.first().toMap().value(QStringLiteral("balance")).toString(), + QStringLiteral("25")); +} + +void WalletPortfolioServiceTest::exposesHoldingDecodeFailureWithoutZeroBalance() +{ + FakeWalletProvider provider; + provider.readResults = { read(DEFINITION_ID, TOKEN_PROGRAM_ID, QStringLiteral("definition")) }; + int decodeCalls = 0; + WalletPortfolioService service(provider, decoder(&decodeCalls, true)); + + WalletPortfolioResult result; + service.refresh(request(), [&result](WalletPortfolioResult next) { + result = std::move(next); + }); + + QCOMPARE(result.status, QStringLiteral("partial")); + QCOMPARE(result.error, QStringLiteral("holding_decode_failed")); + QCOMPARE(result.assets.size(), 1); + 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() +{ + FakeWalletProvider provider; + provider.readResults = { read(DEFINITION_ID, TOKEN_PROGRAM_ID, QStringLiteral("definition")) }; + int decodeCalls = 0; + WalletPortfolioService service(provider, decoder(&decodeCalls)); + WalletPortfolioRequest input = request(); + for (WalletAccountRead& account : input.publicAccountReads) { + if (account.accountId == HOLDING_ID) + account = WalletAccountRead { HOLDING_ID }; + } + + WalletPortfolioResult result; + service.refresh(std::move(input), [&result](WalletPortfolioResult next) { + result = std::move(next); + }); + + QCOMPARE(result.status, QStringLiteral("partial")); + QCOMPARE(result.error, QStringLiteral("public_account_read_failed")); + QCOMPARE(result.assets.size(), 1); + const QVariantMap asset = result.assets.first().toMap(); + QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("unavailable")); + QVERIFY(asset.value(QStringLiteral("balance")).toString().isEmpty()); +} + +void WalletPortfolioServiceTest::onlyDeliversLatestRefresh() +{ + FakeWalletProvider provider; + provider.readResults = { read(DEFINITION_ID, TOKEN_PROGRAM_ID, QStringLiteral("definition")) }; + provider.deferPublicAccountReads = true; + int decodeCalls = 0; + WalletPortfolioService service(provider, decoder(&decodeCalls)); + bool firstCalled = false; + bool secondCalled = false; + + service.refresh(request(), [&firstCalled](WalletPortfolioResult) { + firstCalled = true; + }); + service.refresh(request(), [&secondCalled](WalletPortfolioResult) { + secondCalled = true; + }); + QCOMPARE(provider.publicAccountReadCalls, 1); + + provider.completePendingPublicAccountReads(); + + QVERIFY(!firstCalled); + QVERIFY(secondCalled); +} + +void WalletPortfolioServiceTest::dropsCallbackAfterServiceDestruction() +{ + FakeWalletProvider provider; + provider.readResults = { read(DEFINITION_ID, TOKEN_PROGRAM_ID, QStringLiteral("definition")) }; + provider.deferPublicAccountReads = true; + int decodeCalls = 0; + bool callbackCalled = false; + + { + auto service = std::make_unique(provider, decoder(&decodeCalls)); + service->refresh(request(), [&callbackCalled](WalletPortfolioResult) { + callbackCalled = true; + }); + } + provider.completePendingPublicAccountReads(); + + QVERIFY(!callbackCalled); + QCOMPARE(decodeCalls, 0); +} + +QTEST_GUILESS_MAIN(WalletPortfolioServiceTest) +#include "WalletPortfolioServiceTest.moc" diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml index 71f480b2..032df7fb 100644 --- a/apps/shared/wallet/tests/qml/tst_WalletControl.qml +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -98,6 +98,25 @@ Item { ListModel { } } + Component { + id: portfolioComponent + + QtObject { + property string assetStatus: "ready" + property string assetError: "" + property var assets: [] + } + } + + Component { + id: networkComponent + + QtObject { + property string activeNetwork: "" + property string networkStatus: "ready" + } + } + Component { id: controlComponent Wallet.WalletControl { @@ -180,6 +199,7 @@ Item { section: account.section || "accounts", programName: account.programName || "", accountType: account.accountType || "", + decodedData: account.decodedData || "", visibility: account.visibility || (account.isPublic === false ? "private" : "public"), canBePrimary: account.canBePrimary === undefined ? true : account.canBePrimary, isPrimary: account.isPrimary === true @@ -496,6 +516,114 @@ Item { } } + function test_tokenAssetsRenderInBoxes() { + const fixture = createControl({ + isWalletOpen: true, + assets: [ + { + name: "Held token", + balance: "42", + definitionId: "c".repeat(64), + displayDefinitionId: "base58-held-token", + status: "ready", + section: "assets" + }, + { + name: "Available token", + balance: "0", + definitionId: "d".repeat(64), + displayDefinitionId: "base58-available-token", + status: "ready", + section: "available" + } + ] + }, []) + mouseClick(findChild(fixture.control, "walletAccountButton")) + + const heldRepeater = findChild(fixture.control, "walletAssetRepeater") + verify(heldRepeater, "Held token repeater exists") + let held = null + tryVerify(function() { + held = heldRepeater.itemAt(0) + return held !== null + }) + verify(held, "Held token box exists") + tryCompare(held, "visible", true) + compare(held.implicitHeight, 68) + compare(held.radius, 10) + compare(held.border.width, 1) + compare(held.border.color, "#3f3f46") + + const availableRepeater = findChild(fixture.control, "walletAvailableAssetRepeater") + verify(availableRepeater, "Available token repeater exists") + let available = null + tryVerify(function() { + available = availableRepeater.itemAt(1) + return available !== null + }) + verify(available, "Available token box exists") + compare(available.visible, false) + mouseClick(findChild(fixture.control, "walletAvailableAssetsButton")) + tryCompare(available, "visible", true) + compare(available.implicitHeight, 64) + compare(available.radius, 10) + compare(available.border.width, 1) + compare(available.border.color, "#3f3f46") + } + + function test_usesExplicitPortfolioAndNetworkProviders() { + const fixture = createControl({ + isWalletOpen: true, + activeNetwork: "wallet network", + networkStatus: "error", + assetStatus: "ready", + assets: [{ + name: "Wallet available token", + balance: "0", + definitionId: "a".repeat(64), + status: "ready", + section: "available" + }] + }, []) + const portfolio = createTemporaryObject(portfolioComponent, root, { + assetStatus: "loading", + assets: [{ + name: "Portfolio token", + balance: "42", + definitionId: "b".repeat(64), + status: "ready", + section: "assets" + }] + }) + const network = createTemporaryObject(networkComponent, root, { + activeNetwork: "shared testnet", + networkStatus: "loading" + }) + verify(portfolio && network, "Shared providers exist") + + fixture.control.portfolio = portfolio + fixture.control.network = network + + compare(fixture.control.portfolioProvider, portfolio) + compare(fixture.control.networkProvider, network) + compare(fixture.control.walletAssets[0].name, "Portfolio token") + compare(fixture.control.assetStatus, "loading") + compare(fixture.control.activeNetwork, "shared testnet") + compare(fixture.control.networkStatus, "loading") + + mouseClick(findChild(fixture.control, "walletAccountButton")) + const indicator = findChild(fixture.control, "walletNetworkStatusIndicator") + const networkName = findChild(fixture.control, "walletNetworkName") + const loading = findChild(fixture.control, "walletAssetsLoadingLabel") + const heldAssets = findChild(fixture.control, "walletAssetRepeater") + verify(indicator && networkName && loading && heldAssets, "Provider UI exists") + tryCompare(indicator, "color", "#f59e0b") + tryCompare(networkName, "text", "shared testnet") + tryCompare(loading, "visible", true) + tryCompare(heldAssets, "count", 1) + tryCompare(heldAssets.itemAt(0), "visible", true) + } + function test_accountNavigationKeepsOverviewInsidePopup() { const assets = [] for (let index = 0; index < 10; ++index) { @@ -567,6 +695,37 @@ Item { compare(fixture.control.selectedAddress, userAddress) } + function test_advancedShowsProgramAndDecodedData() { + const decodedData = "{\n \"name\": \"Test token\"\n}" + const fixture = createControl({ isWalletOpen: true }, [ + { + name: "Token definition", + address: "c".repeat(64), + balance: "0", + isPublic: true, + kind: "token_definition", + section: "advanced", + programName: "Token", + accountType: "TokenDefinition", + decodedData: decodedData, + canBePrimary: false + } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + mouseClick(findChild(fixture.control, "walletAdvancedAccountsButton")) + + const list = findChild(fixture.control, "walletAccountList") + tryVerify(function() { return list.itemAtIndex(0) !== null }) + const program = findChild(list.itemAtIndex(0), "walletProgramName") + const decoded = findChild(list.itemAtIndex(0), "walletDecodedData") + verify(program && decoded, "Advanced details exist") + tryCompare(program, "visible", true) + compare(program.text, "Program: Token") + tryCompare(decoded, "visible", true) + compare(decoded.text, decodedData) + } + function test_onlyProgramRecordsLeavesPrimaryEmpty() { const fixture = createControl({ isWalletOpen: true }, [{ name: "Token definition",