From 9049d31e70633d593b5d2589c39b76c75f1f1c46 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Sat, 18 Jul 2026 19:38:14 -0300 Subject: [PATCH] chore(wallet): synchronize shared wallet module --- apps/shared/wallet/CMakeLists.txt | 12 +- .../qml/TransactionConfirmationDialog.qml | 116 +++- apps/shared/wallet/qml/WalletControl.qml | 59 +- .../wallet/qml/internal/AccountDelegate.qml | 2 + .../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 | 355 ++++++++++-- apps/shared/wallet/src/LogosWalletProvider.h | 8 +- apps/shared/wallet/src/WalletAccountId.cpp | 58 +- apps/shared/wallet/src/WalletAccountId.h | 1 + apps/shared/wallet/src/WalletAccountModel.cpp | 32 +- apps/shared/wallet/src/WalletAccountModel.h | 2 +- apps/shared/wallet/src/WalletController.cpp | 199 +++++-- apps/shared/wallet/src/WalletController.h | 8 + apps/shared/wallet/src/WalletProvider.h | 5 + .../tests/cpp/LogosWalletProviderTest.cpp | 521 ++++++++++++++++-- .../wallet/tests/cpp/fixtures/logos_sdk.h | 48 ++ .../wallet/tests/qml/tst_CopyButton.qml | 56 ++ .../qml/tst_TransactionConfirmationDialog.qml | 32 ++ .../wallet/tests/qml/tst_WalletControl.qml | 207 ++++--- .../wallet/tests/support/FakeWalletProvider.h | 38 +- 23 files changed, 1535 insertions(+), 257 deletions(-) create mode 100644 apps/shared/wallet/tests/qml/tst_CopyButton.qml diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 32adf6a..8c32c11 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -61,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 @@ -172,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 141a8db..76cc91a 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 0136de7..1536781 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -17,11 +17,15 @@ Item { 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" @@ -52,7 +56,11 @@ Item { required property bool canBePrimary required property string kind } - onCountChanged: root.syncPrimarySelection() + onCountChanged: { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + onObjectAdded: root.schedulePrimarySelection() } function accountAt(index, field) { @@ -78,6 +86,8 @@ Item { ? 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 @@ -85,6 +95,8 @@ Item { } 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 @@ -93,6 +105,19 @@ Item { 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) @@ -140,6 +165,7 @@ Item { if (key === root.reportedOpenErrorKey) return root.reportedOpenErrorKey = key + root.busy = false root.showError(root.openFailureMessage()) } @@ -234,16 +260,32 @@ Item { Connections { target: root.accountModel ignoreUnknownSignals: true - function onModelReset() { root.syncPrimarySelection() } - function onRowsInserted() { root.syncPrimarySelection() } - function onRowsRemoved() { root.syncPrimarySelection() } - function onDataChanged() { root.syncPrimarySelection() } + 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() } + function onPrimaryAccountAddressChanged() { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } function onWalletSyncStatusChanged() { if (!root.wallet || root.wallet.walletSyncStatus !== "error") root.reportedOpenErrorKey = "" @@ -266,6 +308,7 @@ Item { walletMenu.close() } else { root.syncPrimarySelection() + root.schedulePrimarySelection() } } onViewportWidthChanged: { @@ -374,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 @@ -395,6 +439,7 @@ Item { height: Math.min(implicitHeight, availableMenuHeight) margins: 12 padding: 12 + focus: true closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside function updateAnchor() { @@ -506,6 +551,8 @@ Item { } } Label { + objectName: "walletPrimaryAccountType" + visible: root.selectedIndex >= 0 text: root.selectedIsPublic ? qsTr("Public user account") : qsTr("Private account") color: "#a1a1aa" diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml index b0918fc..ea5da2b 100644 --- a/apps/shared/wallet/qml/internal/AccountDelegate.qml +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -119,6 +119,7 @@ ItemDelegate { spacing: 6 Button { + objectName: "walletRenameButton" text: qsTr("Rename") flat: true onClicked: root.renameRequested(root.address, root.alias) @@ -127,6 +128,7 @@ ItemDelegate { Item { Layout.fillWidth: true } Button { + objectName: "walletMakePrimaryButton" visible: root.canBePrimary && !root.isPrimary text: qsTr("Make primary") flat: true diff --git a/apps/shared/wallet/qml/internal/CopyButton.qml b/apps/shared/wallet/qml/internal/CopyButton.qml index 1964da3..b2f664f 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 51d5f31..b4998da 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 ac422c1..e1ca2de 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 7f7a346..47c3963 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 4a00189..394c01c 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.cpp +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -1,10 +1,13 @@ #include "LogosWalletProvider.h" +#include + #include #include #include #include #include +#include #include #include #include @@ -114,6 +117,58 @@ void applyPublicRead(WalletAccount& account, const WalletAccountRead& read) account.programOwner = read.programOwner; account.dataHex = read.dataHex; } + +bool encodeTransaction(const WalletTransaction& transaction, + QVariantList* signingRequirements, + QVariantList* instruction) +{ + if (!isHex(transaction.programId, 64) + || transaction.accountIds.size() != transaction.signingRequirements.size()) { + return false; + } + for (const QString& accountId : transaction.accountIds) { + if (!isHex(accountId, 64)) + return false; + } + + signingRequirements->reserve(transaction.signingRequirements.size()); + for (bool required : transaction.signingRequirements) + signingRequirements->append(required); + + instruction->reserve(transaction.instruction.size()); + for (quint32 word : transaction.instruction) + instruction->append(word); + return true; +} + +WalletSubmission parseSubmission(const QString& response) +{ + WalletSubmission submission; + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + const QJsonObject result = document.object(); + const QJsonValue success = result.value(QStringLiteral("success")); + const QJsonValue error = result.value(QStringLiteral("error")); + const QString hash = result.value(QStringLiteral("tx_hash")).toString(); + const bool emptyError = error.isUndefined() + || error.isNull() + || (error.isString() && error.toString().isEmpty()); + if (!success.isBool() + || !success.toBool() + || !emptyError + || !isHex(hash, 64, false)) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + submission.nativeHash = hash.toLower(); + return submission; +} } struct LogosWalletProvider::Impl { @@ -143,12 +198,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); @@ -173,6 +232,7 @@ WalletSession LogosWalletProvider::connect(const WalletPaths& paths) void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback callback) { + ++m_sessionGeneration; clearSnapshot(); const quint64 generation = ++m_generation; if (!m_impl->logos) { @@ -236,6 +296,8 @@ void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, const QString& password) { + ++m_generation; + ++m_sessionGeneration; clearSnapshot(); if (!m_impl->logos) return failedCreation(WalletFailure::WalletUnavailable); @@ -260,8 +322,6 @@ WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, return creation; } - creation.snapshot = snapshot(true); - creation.failure = creation.snapshot.failure; return creation; } @@ -329,19 +389,203 @@ WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic) return creation; } - creation.snapshot = snapshot(true); - if (isPublic) { - for (const WalletAccountRead& read : creation.snapshot.publicAccountReads) { - if (read.accountId == creation.accountId) { - creation.publicAccount = read; - return creation; - } - } + if (isPublic) creation.publicAccount = readPublicAccount(creation.accountId); + 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 { if (!m_impl->logos || !isHex(accountId, 64)) @@ -399,27 +643,12 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction( submission.failure = WalletFailure::WalletUnavailable; return submission; } - if (!isHex(transaction.programId, 64) - || transaction.accountIds.size() != transaction.signingRequirements.size()) { + QVariantList signingRequirements; + QVariantList instruction; + if (!encodeTransaction(transaction, &signingRequirements, &instruction)) { submission.failure = WalletFailure::InvalidRequest; return submission; } - for (const QString& accountId : transaction.accountIds) { - if (!isHex(accountId, 64)) { - submission.failure = WalletFailure::InvalidRequest; - return submission; - } - } - - QVariantList signingRequirements; - signingRequirements.reserve(transaction.signingRequirements.size()); - for (bool required : transaction.signingRequirements) - signingRequirements.append(required); - - QVariantList instruction; - instruction.reserve(transaction.instruction.size()); - for (quint32 word : transaction.instruction) - instruction.append(word); const QString response = m_impl->logos->logos_execution_zone.send_generic_public_transaction( @@ -427,36 +656,60 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction( signingRequirements, QVariant::fromValue(instruction), transaction.programId); + return parseSubmission(response); +} - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) { - submission.failure = WalletFailure::SubmissionFailed; - return submission; +void LogosWalletProvider::submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) +{ + QPointer guard(this); + WalletSubmission submission; + if (!m_connected || !m_impl->logos) { + submission.failure = WalletFailure::WalletUnavailable; + QTimer::singleShot(0, [guard, callback = std::move(callback), + submission = std::move(submission)]() mutable { + if (guard) + callback(std::move(submission)); + }); + return; } - const QJsonObject result = document.object(); - const QJsonValue success = result.value(QStringLiteral("success")); - const QJsonValue error = result.value(QStringLiteral("error")); - const QString hash = result.value(QStringLiteral("tx_hash")).toString(); - const bool emptyError = error.isUndefined() - || error.isNull() - || (error.isString() && error.toString().isEmpty()); - if (!success.isBool() - || !success.toBool() - || !emptyError - || !isHex(hash, 64, false)) { - submission.failure = WalletFailure::SubmissionFailed; - return submission; + QVariantList signingRequirements; + QVariantList instruction; + if (!encodeTransaction(transaction, &signingRequirements, &instruction)) { + submission.failure = WalletFailure::InvalidRequest; + QTimer::singleShot(0, [guard, callback = std::move(callback), + submission = std::move(submission)]() mutable { + if (guard) + callback(std::move(submission)); + }); + return; } - submission.nativeHash = hash.toLower(); - return submission; + const quint64 sessionGeneration = m_sessionGeneration; + m_impl->logos->logos_execution_zone.send_generic_public_transactionAsync( + transaction.accountIds, + signingRequirements, + QVariant::fromValue(instruction), + transaction.programId, + [guard, sessionGeneration, callback = std::move(callback)]( + QString response) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletSubmission failed; + failed.failure = WalletFailure::WalletUnavailable; + callback(std::move(failed)); + return; + } + callback(parseSubmission(response)); + }); } void LogosWalletProvider::disconnect() { ++m_generation; + ++m_sessionGeneration; if (m_connected) save(); clearSnapshot(); @@ -589,16 +842,12 @@ void LogosWalletProvider::loadSnapshotAsync(quint64 generation, SnapshotCallback {}, entry.value(QStringLiteral("is_public"), true).toBool(), }; - if (!state->snapshot.accounts.at(index).isPublic) { - state->snapshot.accounts[index].readStatus = - QStringLiteral("private"); - } state->publicFlags[index] = state->snapshot.accounts.at(index).isPublic; } auto finishOne = std::make_shared>(); - *finishOne = [this, generation, state, finishOne]() mutable { + *finishOne = [this, generation, state]() mutable { if (generation != m_generation || --state->remaining > 0) return; for (qsizetype index = 0; diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h index a22aac8..32b0137 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.h +++ b/apps/shared/wallet/src/LogosWalletProvider.h @@ -2,12 +2,14 @@ #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); @@ -21,11 +23,14 @@ public: void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override; void clearSnapshot() override; WalletAccountCreation createAccount(bool isPublic) override; + void createAccountAsync(bool isPublic, AccountCreationCallback callback) override; WalletAccountRead readPublicAccount(const QString& accountId) const override; 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: @@ -40,4 +45,5 @@ private: 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 index 76903c7..4d3d063 100644 --- a/apps/shared/wallet/src/WalletAccountId.cpp +++ b/apps/shared/wallet/src/WalletAccountId.cpp @@ -6,6 +6,9 @@ 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) { @@ -14,6 +17,18 @@ bool isHexCharacter(QChar character) || (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) @@ -26,7 +41,7 @@ QString walletAccountIdToBase58(const QString& accountId) } const QByteArray bytes = QByteArray::fromHex(accountId.toLatin1()); - if (bytes.size() != 32) + if (bytes.size() != ACCOUNT_ID_BYTES) return {}; qsizetype leadingZeroes = 0; @@ -54,3 +69,44 @@ QString walletAccountIdToBase58(const QString& accountId) 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 index ad7cf3e..57fd8a7 100644 --- a/apps/shared/wallet/src/WalletAccountId.h +++ b/apps/shared/wallet/src/WalletAccountId.h @@ -3,3 +3,4 @@ #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 f9b5262..8b93327 100644 --- a/apps/shared/wallet/src/WalletAccountModel.cpp +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -127,7 +127,7 @@ void WalletAccountModel::replaceAccounts(const QVector& accounts, emit countChanged(); } -void WalletAccountModel::applyPresentations( +bool WalletAccountModel::applyPresentations( const QVector& presentations) { QHash rowsByAddress; @@ -141,10 +141,14 @@ void WalletAccountModel::applyPresentations( int firstChanged = m_accounts.size(); int lastChanged = -1; for (const WalletAccountPresentation& presentation : presentations) { - const auto row = rowsByAddress.constFind(presentation.address); + 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; - Entry& entry = m_accounts[row.value()]; + const Entry current = m_accounts.at(row.value()); + Entry entry = current; if (!presentation.kind.isEmpty()) entry.kind = presentation.kind; entry.programName = presentation.programName; @@ -157,13 +161,32 @@ void WalletAccountModel::applyPresentations( 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; + return false; emit dataChanged(index(firstChanged), index(lastChanged), { NameRole, KindRole, @@ -174,6 +197,7 @@ void WalletAccountModel::applyPresentations( IsPrimaryRole, DefinitionIdRole, }); + return true; } void WalletAccountModel::setAlias(const QString& address, const QString& alias) diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h index 86acf4f..8e21a58 100644 --- a/apps/shared/wallet/src/WalletAccountModel.h +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -51,7 +51,7 @@ public: void replaceAccounts(const QVector& accounts, const QHash& aliases = {}, const QString& primaryAddress = {}); - void applyPresentations(const QVector& presentations); + bool applyPresentations(const QVector& presentations); void setAlias(const QString& address, const QString& alias); void setPrimaryAddress(const QString& address); bool contains(const QString& address) const; diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp index ddf915b..9a64970 100644 --- a/apps/shared/wallet/src/WalletController.cpp +++ b/apps/shared/wallet/src/WalletController.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include #include @@ -96,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); } @@ -141,73 +187,102 @@ bool WalletController::beginOpen(const QString& config, const QString& storage) emit stateChanged(); } }); + const QPointer guard(this); m_wallet.connectAsync({ config, storage }, - [this, generation, config, storage](WalletSession session) { - if (generation != m_operationGeneration) + [guard, generation, config, storage](WalletSession session) { + if (!guard || generation != guard->m_operationGeneration) return; if (session.failure == WalletFailure::WalletMissing) { - m_state.syncStatus = QStringLiteral("closed"); - m_state.walletExists = false; - emit stateChanged(); + 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); - m_state.syncStatus = QStringLiteral("error"); - m_state.syncError = walletFailureCode(session.failure); - emit stateChanged(); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(session.failure); + emit guard->stateChanged(); return; } - m_state.configPath = config; - m_state.storagePath = storage; - m_state.walletExists = QFileInfo::exists(storage) || session.adopted; - m_state.isWalletOpen = true; - m_state.syncStatus = QStringLiteral("ready"); - applySnapshot(session.snapshot); + 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( { config, storage }, password); - const bool createdButUnreadable = creation.failure == WalletFailure::ReadFailed; - if (creation.mnemonic.isEmpty() - || (!creation.ok() && !createdButUnreadable)) { + if (creation.mnemonic.isEmpty()) { qWarning() << "WalletController: wallet creation failed" << 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); - m_state.isWalletOpen = true; - if (!creation.snapshot.ok()) { - qWarning() << "WalletController: wallet creation refresh failed" - << walletFailureCode(creation.snapshot.failure); + 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.snapshot.failure); + m_state.syncError = walletFailureCode(creation.failure); emit stateChanged(); return creation.mnemonic; } - m_state.syncStatus = QStringLiteral("ready"); + m_state.walletExists = true; + m_state.isWalletOpen = true; + m_state.syncStatus = QStringLiteral("syncing"); m_state.syncError.clear(); - applySnapshot(creation.snapshot); + 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; } @@ -224,14 +299,11 @@ bool WalletController::open() void WalletController::disconnect() { ++m_operationGeneration; + stopReachability(); m_wallet.disconnect(); m_state.isWalletOpen = false; m_state.syncStatus = QStringLiteral("closed"); m_state.syncError.clear(); - m_state.primaryAccountAddress.clear(); - m_state.primaryAccountName.clear(); - m_snapshot = {}; - m_aliases.clear(); m_accountModel->replaceAccounts({}); QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); emit stateChanged(); @@ -270,14 +342,22 @@ bool WalletController::setPrimaryAccount(const QString& address) void WalletController::applyAccountPresentations( const QVector& presentations) { - m_accountModel->applyPresentations(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); - storePrimaryAccount(primary); + if (primary != previousPrimary) + storePrimaryAccount(primary); updatePrimaryState(primary); - emit stateChanged(); + if (m_state.primaryAccountAddress != previousPrimary + || m_state.primaryAccountName != previousPrimaryName) { + emit stateChanged(); + } } QString WalletController::createAccount(bool isPublic) @@ -310,18 +390,19 @@ void WalletController::refresh() m_state.syncStatus = QStringLiteral("syncing"); m_state.syncError.clear(); emit stateChanged(); - m_wallet.snapshotAsync(true, [this, generation](WalletSnapshot next) { - if (generation != m_operationGeneration) + const QPointer guard(this); + m_wallet.snapshotAsync(true, [guard, generation](WalletSnapshot next) { + if (!guard || generation != guard->m_operationGeneration) return; if (next.ok()) { - m_state.syncStatus = QStringLiteral("ready"); - applySnapshot(next); + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(next); } else { qWarning() << "WalletController: wallet refresh failed" << walletFailureCode(next.failure); - m_state.syncStatus = QStringLiteral("error"); - m_state.syncError = walletFailureCode(next.failure); - emit stateChanged(); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(next.failure); + emit guard->stateChanged(); } }); } @@ -353,6 +434,8 @@ void WalletController::applySnapshot(const WalletSnapshot& snapshot) m_state.sequencerAddress = snapshot.sequencerAddress; emit snapshotChanged(); emit stateChanged(); + if (!m_reachabilityTimer->isActive()) + m_reachabilityTimer->start(); checkReachability(); } @@ -417,16 +500,44 @@ void WalletController::updatePrimaryState(const QString& address) } } +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 6d8ae82..e30f733 100644 --- a/apps/shared/wallet/src/WalletController.h +++ b/apps/shared/wallet/src/WalletController.h @@ -8,6 +8,7 @@ #include "WalletProvider.h" class QNetworkAccessManager; +class QNetworkReply; class QTimer; class WalletAccountModel; struct WalletAccountPresentation; @@ -48,6 +49,7 @@ public: 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); @@ -70,11 +72,13 @@ 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; @@ -89,7 +93,11 @@ private: 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 94fb225..434b563 100644 --- a/apps/shared/wallet/src/WalletProvider.h +++ b/apps/shared/wallet/src/WalletProvider.h @@ -99,6 +99,8 @@ public: using SessionCallback = std::function; using SnapshotCallback = std::function; using AccountReadsCallback = std::function)>; + using AccountCreationCallback = std::function; + using SubmissionCallback = std::function; virtual ~WalletProvider() = default; @@ -110,10 +112,13 @@ public: virtual void snapshotAsync(bool forceRefresh, SnapshotCallback callback) = 0; virtual void clearSnapshot() = 0; virtual WalletAccountCreation createAccount(bool isPublic) = 0; + virtual void createAccountAsync(bool isPublic, AccountCreationCallback callback) = 0; virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0; virtual 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 b9cf417..43d839f 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -1,13 +1,20 @@ +#include #include #include #include +#include #include #include #include +#include +#include #include #include #include +#include +#include + #include "FakeWalletProvider.h" #include "LogosWalletProvider.h" #include "WalletAccountId.h" @@ -22,6 +29,30 @@ 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"), const QString& nonce = QString(32, QLatin1Char('0')), @@ -59,16 +90,28 @@ private slots: void fallsBackToBalanceWhenPublicReadFails(); void createsAndPersistsAccounts(); void preservesCreatedAccountWhenPublicReadFails(); - void preservesCreatedAccountWhenSnapshotRefreshFails(); + void createdAccountDoesNotRescanWallet(); void dispatchesExactGenericTransaction(); void rejectsInvalidSubmissionResponses(); - void encodesAccountIdsForDisplay(); + void walletMutationsUseAsyncSdk(); + void staleAsyncMutationCannotCrossSession(); + void destroyedProviderIgnoresLateMutation(); void exposesStableAccountModelRoles(); + void encodesAccountIdsForDisplay(); void persistsHumanizedWalletPreferences(); void fakeProviderImplementsConsumerContract(); void controllerOwnsUiWalletFlow(); - void controllerReportsCreationPersistenceAndRefreshFailures(); + 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() @@ -103,6 +146,7 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() 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); @@ -110,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'))); @@ -236,6 +281,9 @@ void LogosWalletProviderTest::createsAndPersistsWallet() 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")), @@ -249,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(); @@ -351,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"); @@ -362,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() @@ -427,6 +480,108 @@ void LogosWalletProviderTest::rejectsInvalidSubmissionResponses() WalletFailure::InvalidRequest); } +void LogosWalletProviderTest::walletMutationsUseAsyncSdk() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + modules.logos_execution_zone.transactionResponse = QStringLiteral( + R"({"success":true,"tx_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"})"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + modules.logos_execution_zone.deferPublicAccountCreation = true; + bool creationFinished = false; + WalletAccountCreation creation; + const int listCalls = modules.logos_execution_zone.listCalls; + const int syncCalls = modules.logos_execution_zone.syncCalls; + provider.createAccountAsync(true, [&](WalletAccountCreation result) { + creation = std::move(result); + creationFinished = true; + }); + QVERIFY(!creationFinished); + QVERIFY(modules.logos_execution_zone.pendingPublicAccountCreation); + modules.logos_execution_zone.finishPublicAccountCreation(); + QVERIFY(creationFinished); + QVERIFY(creation.ok()); + QVERIFY(creation.publicAccount.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QCOMPARE(creation.snapshot.accounts.size(), 1); + QCOMPARE(modules.logos_execution_zone.listCalls, listCalls); + QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls); + + WalletTransaction transaction { + PROGRAM_ID, + { ACCOUNT_A, ACCOUNT_B }, + { true, false }, + { 7, 0, 4294967295U }, + }; + modules.logos_execution_zone.deferSubmission = true; + bool submissionFinished = false; + WalletSubmission submission; + provider.submitPublicTransactionAsync( + transaction, [&](WalletSubmission result) { + submission = std::move(result); + submissionFinished = true; + }); + QVERIFY(!submissionFinished); + QCOMPARE(modules.logos_execution_zone.submittedProgramId, PROGRAM_ID); + QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds); + QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements, + QVariantList({ true, false })); + QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(), + QVariantList({ 7U, 0U, 4294967295U })); + modules.logos_execution_zone.finishSubmission(); + QVERIFY(submissionFinished); + QVERIFY(submission.accepted()); + QCOMPARE(submission.nativeHash, QString(64, QLatin1Char('a'))); +} + +void LogosWalletProviderTest::staleAsyncMutationCannotCrossSession() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + modules.logos_execution_zone.deferPublicAccountCreation = true; + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + int callbackCount = 0; + WalletAccountCreation creation; + provider.createAccountAsync(true, [&](WalletAccountCreation result) { + ++callbackCount; + creation = std::move(result); + }); + provider.disconnect(); + modules.logos_execution_zone.finishPublicAccountCreation(); + + QCOMPARE(callbackCount, 1); + QCOMPARE(creation.failure, WalletFailure::WalletUnavailable); + QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0); +} + +void LogosWalletProviderTest::destroyedProviderIgnoresLateMutation() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.transactionResponse = QStringLiteral( + R"({"success":true,"tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"); + modules.logos_execution_zone.deferSubmission = true; + int callbackCount = 0; + { + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + provider.submitPublicTransactionAsync( + { PROGRAM_ID, { ACCOUNT_A }, { true }, { 1 } }, + [&](WalletSubmission) { ++callbackCount; }); + } + + modules.logos_execution_zone.finishSubmission(); + QCOMPARE(callbackCount, 0); +} + void LogosWalletProviderTest::exposesStableAccountModelRoles() { WalletAccountModel model; @@ -459,7 +614,7 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() QVERIFY(!model.data(model.index(2), WalletAccountModel::CanBePrimaryRole).toBool()); QSignalSpy presentationsChanged(&model, &QAbstractItemModel::dataChanged); - model.applyPresentations({ + const QVector presentations { { ACCOUNT_A, QStringLiteral("program"), @@ -470,7 +625,7 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() false, }, { - ACCOUNT_C, + walletAccountIdToBase58(ACCOUNT_C), QStringLiteral("token_holding"), QStringLiteral("TEST holding"), QStringLiteral("Token"), @@ -478,7 +633,8 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() ACCOUNT_A, true, }, - }); + }; + model.applyPresentations(presentations); QCOMPARE(presentationsChanged.count(), 1); QCOMPARE(model.data(model.index(2), WalletAccountModel::SectionRole).toString(), QStringLiteral("hidden")); @@ -490,6 +646,10 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() 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() @@ -497,9 +657,16 @@ 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() @@ -599,62 +766,161 @@ void LogosWalletProviderTest::controllerOwnsUiWalletFlow() settings.clear(); } -void LogosWalletProviderTest::controllerReportsCreationPersistenceAndRefreshFailures() +void LogosWalletProviderTest::controllerSeparatesSnapshotsFromCosmeticState() { - const QString settingsApplication = QStringLiteral("WalletCreationFailureTest"); + 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); - const WalletUiState baseline = controller.state(); - const int baselineAccountCount = controller.accountModel()->count(); + QSignalSpy stateChanged(&controller, &WalletController::stateChanged); QSignalSpy snapshotChanged(&controller, &WalletController::snapshotChanged); - provider.createWalletResult.mnemonic = QStringLiteral("alpha beta gamma"); - provider.createWalletResult.failure = WalletFailure::SaveFailed; - provider.createWalletResult.snapshot.failure = WalletFailure::SaveFailed; - QCOMPARE(controller.createWallet(QStringLiteral("config"), QStringLiteral("storage"), - QStringLiteral("secret")), - QString()); - QCOMPARE(controller.state().isWalletOpen, baseline.isWalletOpen); - QCOMPARE(controller.state().walletExists, baseline.walletExists); - QCOMPARE(controller.state().syncStatus, baseline.syncStatus); - QCOMPARE(controller.state().syncError, baseline.syncError); - QCOMPARE(controller.accountModel()->count(), baselineAccountCount); + QVERIFY(controller.open()); + stateChanged.clear(); + snapshotChanged.clear(); + + QVERIFY(controller.setAccountAlias(ACCOUNT_A, QStringLiteral("Spending"))); + QCOMPARE(stateChanged.count(), 1); QCOMPARE(snapshotChanged.count(), 0); - provider.createWalletResult.failure = WalletFailure::ReadFailed; - provider.createWalletResult.snapshot.failure = WalletFailure::ReadFailed; - QCOMPARE(controller.createWallet(QStringLiteral("config"), QStringLiteral("storage"), - QStringLiteral("secret")), - QStringLiteral("alpha beta gamma")); - QVERIFY(controller.state().isWalletOpen); - QVERIFY(controller.state().walletExists); - QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); - QCOMPARE(controller.state().syncError, QStringLiteral("read_failed")); - QCOMPARE(controller.accountModel()->count(), baselineAccountCount); - QCOMPARE(snapshotChanged.count(), 0); - - provider.createAccountResult.accountId = ACCOUNT_B; - provider.createAccountResult.snapshot.failure = WalletFailure::ReadFailed; - QCOMPARE(controller.createAccount(true), ACCOUNT_B); - QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); - QCOMPARE(controller.state().syncError, QStringLiteral("read_failed")); - QCOMPARE(controller.accountModel()->count(), baselineAccountCount); - QCOMPARE(snapshotChanged.count(), 0); - - provider.createAccountResult.snapshot = {}; - provider.createAccountResult.snapshot.accounts = { - { ACCOUNT_A, QStringLiteral("5"), true, QStringLiteral("ok"), EOA_OWNER, {} }, - { ACCOUNT_B, QStringLiteral("3"), true, QStringLiteral("ok"), EOA_OWNER, {} }, - }; - QCOMPARE(controller.createAccount(true), ACCOUNT_B); - QCOMPARE(controller.state().syncStatus, QStringLiteral("ready")); - QVERIFY(controller.state().syncError.isEmpty()); - QCOMPARE(controller.accountModel()->count(), 2); + 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(); } @@ -673,19 +939,164 @@ void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() QVERIFY(controller.open()); QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000); - controller.disconnect(); - finished.clear(); - auto* timer = controller.findChild(); QVERIFY(timer); + QVERIFY(timer->isActive()); + controller.disconnect(); + QVERIFY(!timer->isActive()); + finished.clear(); + timer->setInterval(1); controller.start(); QTest::qWait(50); + QVERIFY(!timer->isActive()); QCOMPARE(finished.count(), 0); settings.clear(); } +void LogosWalletProviderTest::completedAsyncSnapshotReleasesCallback() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + bool completed = false; + std::weak_ptr 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 13ca5d7..87cf30f 100644 --- a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h +++ b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h @@ -7,6 +7,7 @@ #include #include +#include class LogosAPI; @@ -32,6 +33,10 @@ public: 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; @@ -77,6 +82,17 @@ public: 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; } @@ -150,6 +166,38 @@ public: submittedProgramId = programId; return transactionResponse; } + + void send_generic_public_transactionAsync( + const QStringList& accountIds, + const QVariantList& signingRequirements, + const QVariant& instruction, + const QString& programId, + std::function 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 0000000..32c9aba --- /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 898a245..01768e8 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 3a07e34..71f480b 100644 --- a/apps/shared/wallet/tests/qml/tst_WalletControl.qml +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -20,6 +20,7 @@ Item { 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 @@ -37,9 +38,11 @@ Item { function openExisting() { openCalls++ - if (completeOpenImmediately) { - walletSyncStatus = "ready" + if (deferOpen) { + walletSyncStatus = "opening" + } else { isWalletOpen = true + walletSyncStatus = "ready" } return true } @@ -192,79 +195,17 @@ Item { tryCompare(fixture.control, "connected", true) } - function test_disablesConnectWhileWalletIsOpening() { - const fixture = createControl({ - walletExists: true, - walletSyncStatus: "syncing" - }, []) - const connectButton = findChild(fixture.control, "walletConnectButton") - verify(!connectButton.enabled) - compare(connectButton.text, "Connecting…") - mouseClick(connectButton) - compare(fixture.backend.openCalls, 0) - } - - function test_showsAsyncOpenFailure() { - const fixture = createControl({ - walletExists: true, - completeOpenImmediately: false - }, []) - const connectButton = findChild(fixture.control, "walletConnectButton") - mouseClick(connectButton) + function test_surfacesDeferredOpenFailure() { + const fixture = createControl({ walletExists: true, deferOpen: true }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) compare(fixture.backend.openCalls, 1) - verify(fixture.control.busy) + compare(fixture.control.syncStatus, "opening") fixture.backend.walletSyncStatus = "error" fixture.backend.walletSyncError = "open_failed" - const dialog = findChild(fixture.control, "walletMessageDialog") tryCompare(dialog, "opened", true) - compare(fixture.control.busy, false) - compare(dialog.message, "Wallet could not be opened: open_failed") - } - - function test_showsAsyncMissingWallet() { - const fixture = createControl({ - walletExists: true, - completeOpenImmediately: false - }, []) - mouseClick(findChild(fixture.control, "walletConnectButton")) - verify(fixture.control.busy) - - fixture.backend.walletExists = false - - const dialog = findChild(fixture.control, "walletMessageDialog") - tryCompare(dialog, "opened", true) - compare(fixture.control.busy, false) - compare(dialog.message, "Wallet could not be opened.") - } - - function test_showsStartupOpenFailure() { - const fixture = createControl({ - walletExists: true, - walletSyncStatus: "error", - walletSyncError: "open_failed" - }, []) - const dialog = findChild(fixture.control, "walletMessageDialog") - tryCompare(dialog, "opened", true) - compare(dialog.message, "Wallet could not be opened: open_failed") - } - - function test_cancellingWalletCreationDoesNotClaimSuccess() { - const fixture = createControl({ walletExists: false }, []) - mouseClick(findChild(fixture.control, "walletConnectButton")) - const creation = findChild(fixture.control, "createWalletDialog") - tryCompare(creation, "opened", true) - - fixture.backend.walletSyncStatus = "error" - fixture.backend.walletSyncError = "wallet_unavailable" - const message = findChild(fixture.control, "walletMessageDialog") - tryCompare(message, "opened", true) - compare(message.message, "Wallet could not be opened: wallet_unavailable") - - creation.close() - wait(0) - compare(message.message, "Wallet could not be opened: wallet_unavailable") + verify(dialog.message.includes("open_failed")) } function test_requiresSeedBackupAcknowledgement() { @@ -357,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 } @@ -370,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 } @@ -427,6 +440,62 @@ Item { 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) { diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h index 1beb671..e86720a 100644 --- a/apps/shared/wallet/tests/support/FakeWalletProvider.h +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -35,6 +35,9 @@ public: AccountReadsCallback callback; }; QVector pendingPublicAccountReads; + bool deferAsync = false; + SessionCallback pendingConnectCallback; + SnapshotCallback pendingSnapshotCallback; WalletSession connect(const WalletPaths& paths) override { @@ -47,7 +50,10 @@ public: { ++connectCalls; lastPaths = paths; - callback(connectResult); + if (deferAsync) + pendingConnectCallback = std::move(callback); + else + callback(connectResult); } WalletCreation createWallet(const WalletPaths& paths, @@ -69,7 +75,10 @@ public: { ++snapshotCalls; lastForceRefresh = forceRefresh; - callback(snapshotResult); + if (deferAsync) + pendingSnapshotCallback = std::move(callback); + else + callback(snapshotResult); } void clearSnapshot() override { ++clearCalls; } @@ -81,6 +90,11 @@ public: return createAccountResult; } + void createAccountAsync(bool isPublic, AccountCreationCallback callback) override + { + callback(createAccount(isPublic)); + } + WalletAccountRead readPublicAccount(const QString& accountId) const override { ++readCalls; @@ -117,8 +131,28 @@ public: 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 {