chore(wallet): synchronize shared wallet module

This commit is contained in:
Ricardo Guilherme Schmidt
2026-07-18 19:38:14 -03:00
parent b71be34edf
commit 9049d31e70
23 changed files with 1535 additions and 257 deletions
+10 -2
View File
@@ -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()
@@ -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
}
}
}
}
+53 -6
View File
@@ -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"
@@ -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
+20 -1
View File
@@ -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()
@@ -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"
@@ -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
@@ -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()
+302 -53
View File
@@ -1,10 +1,13 @@
#include "LogosWalletProvider.h"
#include <algorithm>
#include <QDir>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QPointer>
#include <QTimer>
#include <QVariantList>
#include <QVariantMap>
@@ -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<LogosWalletProvider> guard(this);
if (!m_connected || !m_impl->logos) {
QTimer::singleShot(0, [guard, callback = std::move(callback)]() mutable {
if (!guard)
return;
WalletAccountCreation creation;
creation.failure = WalletFailure::WalletUnavailable;
callback(std::move(creation));
});
return;
}
const quint64 sessionGeneration = m_sessionGeneration;
auto finish = [guard, sessionGeneration, isPublic,
callback = std::move(callback)](
WalletAccountCreation creation, QString fallbackBalance) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
callback(std::move(failed));
return;
}
if (guard->m_snapshotReady) {
WalletAccount account;
account.address = creation.accountId;
account.isPublic = isPublic;
if (isPublic && creation.publicAccount.ok()) {
account.balance = littleEndianU128ToDecimal(
creation.publicAccount.balanceHex);
auto read = std::find_if(
guard->m_snapshot.publicAccountReads.begin(),
guard->m_snapshot.publicAccountReads.end(),
[&creation](const WalletAccountRead& existing) {
return existing.accountId == creation.accountId;
});
if (read == guard->m_snapshot.publicAccountReads.end())
guard->m_snapshot.publicAccountReads.append(creation.publicAccount);
else
*read = creation.publicAccount;
} else {
account.balance = std::move(fallbackBalance);
}
auto existing = std::find_if(
guard->m_snapshot.accounts.begin(),
guard->m_snapshot.accounts.end(),
[&creation](const WalletAccount& candidate) {
return candidate.address == creation.accountId;
});
if (existing == guard->m_snapshot.accounts.end())
guard->m_snapshot.accounts.append(account);
else
*existing = account;
creation.snapshot = guard->m_snapshot;
}
callback(std::move(creation));
};
auto created = [guard, sessionGeneration, isPublic,
finish = std::move(finish)](QString accountId) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
WalletAccountCreation creation;
creation.accountId = std::move(accountId);
if (!isHex(creation.accountId, 64)) {
creation.failure = WalletFailure::CreateFailed;
finish(std::move(creation), {});
return;
}
guard->m_impl->logos->logos_execution_zone.saveAsync(
[guard, sessionGeneration, isPublic, creation = std::move(creation),
finish = std::move(finish)](int result) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
if (result != WALLET_FFI_SUCCESS) {
creation.failure = WalletFailure::SaveFailed;
finish(std::move(creation), {});
return;
}
if (!isPublic) {
guard->m_impl->logos->logos_execution_zone.get_balanceAsync(
creation.accountId, false,
[guard, sessionGeneration, creation = std::move(creation),
finish = std::move(finish)](QString balance) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
finish(std::move(creation), std::move(balance));
});
return;
}
const QString accountId = creation.accountId;
guard->m_impl->logos->logos_execution_zone.get_account_publicAsync(
accountId,
[guard, sessionGeneration, creation = std::move(creation),
finish = std::move(finish)](QString payload) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
creation.publicAccount = parsePublicAccount(
creation.accountId, payload);
if (creation.publicAccount.ok()) {
finish(std::move(creation), {});
return;
}
guard->m_impl->logos->logos_execution_zone.get_balanceAsync(
creation.accountId, true,
[guard, sessionGeneration,
creation = std::move(creation),
finish = std::move(finish)](QString balance) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletAccountCreation failed;
failed.failure = WalletFailure::WalletUnavailable;
finish(std::move(failed), {});
return;
}
finish(std::move(creation), std::move(balance));
});
});
});
};
if (isPublic)
m_impl->logos->logos_execution_zone.create_account_publicAsync(std::move(created));
else
m_impl->logos->logos_execution_zone.create_account_privateAsync(std::move(created));
}
WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const
{
if (!m_impl->logos || !isHex(accountId, 64))
@@ -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<LogosWalletProvider> guard(this);
WalletSubmission submission;
if (!m_connected || !m_impl->logos) {
submission.failure = WalletFailure::WalletUnavailable;
QTimer::singleShot(0, [guard, callback = std::move(callback),
submission = std::move(submission)]() mutable {
if (guard)
callback(std::move(submission));
});
return;
}
const QJsonObject result = document.object();
const QJsonValue success = result.value(QStringLiteral("success"));
const QJsonValue error = result.value(QStringLiteral("error"));
const QString hash = result.value(QStringLiteral("tx_hash")).toString();
const bool emptyError = error.isUndefined()
|| error.isNull()
|| (error.isString() && error.toString().isEmpty());
if (!success.isBool()
|| !success.toBool()
|| !emptyError
|| !isHex(hash, 64, false)) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
QVariantList signingRequirements;
QVariantList instruction;
if (!encodeTransaction(transaction, &signingRequirements, &instruction)) {
submission.failure = WalletFailure::InvalidRequest;
QTimer::singleShot(0, [guard, callback = std::move(callback),
submission = std::move(submission)]() mutable {
if (guard)
callback(std::move(submission));
});
return;
}
submission.nativeHash = hash.toLower();
return submission;
const quint64 sessionGeneration = m_sessionGeneration;
m_impl->logos->logos_execution_zone.send_generic_public_transactionAsync(
transaction.accountIds,
signingRequirements,
QVariant::fromValue(instruction),
transaction.programId,
[guard, sessionGeneration, callback = std::move(callback)](
QString response) mutable {
if (!guard)
return;
if (sessionGeneration != guard->m_sessionGeneration) {
WalletSubmission failed;
failed.failure = WalletFailure::WalletUnavailable;
callback(std::move(failed));
return;
}
callback(parseSubmission(response));
});
}
void LogosWalletProvider::disconnect()
{
++m_generation;
++m_sessionGeneration;
if (m_connected)
save();
clearSnapshot();
@@ -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<std::function<void()>>();
*finishOne = [this, generation, state, finishOne]() mutable {
*finishOne = [this, generation, state]() mutable {
if (generation != m_generation || --state->remaining > 0)
return;
for (qsizetype index = 0;
+7 -1
View File
@@ -2,12 +2,14 @@
#include <memory>
#include <QObject>
#include "WalletProvider.h"
class LogosAPI;
struct LogosModules;
class LogosWalletProvider final : public WalletProvider {
class LogosWalletProvider final : public QObject, public WalletProvider {
public:
explicit LogosWalletProvider(LogosAPI* api);
explicit LogosWalletProvider(LogosModules* logos);
@@ -21,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;
};
+57 -1
View File
@@ -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<char>(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<unsigned char> 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<int>(byte) * 58;
byte = static_cast<unsigned char>(carry % 256);
carry /= 256;
}
while (carry > 0) {
bytes.append(static_cast<unsigned char>(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<char>(bytes.at(index));
return QString::fromLatin1(decoded.toHex());
}
+1
View File
@@ -3,3 +3,4 @@
#include <QString>
QString walletAccountIdToBase58(const QString& accountId);
QString walletAccountIdFromBase58(const QString& accountId);
+28 -4
View File
@@ -127,7 +127,7 @@ void WalletAccountModel::replaceAccounts(const QVector<WalletAccount>& accounts,
emit countChanged();
}
void WalletAccountModel::applyPresentations(
bool WalletAccountModel::applyPresentations(
const QVector<WalletAccountPresentation>& presentations)
{
QHash<QString, int> 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)
+1 -1
View File
@@ -51,7 +51,7 @@ public:
void replaceAccounts(const QVector<WalletAccount>& accounts,
const QHash<QString, QString>& aliases = {},
const QString& primaryAddress = {});
void applyPresentations(const QVector<WalletAccountPresentation>& presentations);
bool applyPresentations(const QVector<WalletAccountPresentation>& presentations);
void setAlias(const QString& address, const QString& alias);
void setPrimaryAddress(const QString& address);
bool contains(const QString& address) const;
+155 -44
View File
@@ -12,6 +12,8 @@
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QSaveFile>
#include <QSettings>
#include <QTimer>
#include <QUrl>
@@ -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<WalletController> 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<WalletController> 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<WalletAccountPresentation>& 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<WalletController> 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;
}
@@ -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<QString, QString> loadAliases() const;
QString loadPrimaryAccount() const;
@@ -89,7 +93,11 @@ private:
QHash<QString, QString> 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;
};
+5
View File
@@ -99,6 +99,8 @@ public:
using SessionCallback = std::function<void(WalletSession)>;
using SnapshotCallback = std::function<void(WalletSnapshot)>;
using AccountReadsCallback = std::function<void(QVector<WalletAccountRead>)>;
using AccountCreationCallback = std::function<void(WalletAccountCreation)>;
using SubmissionCallback = std::function<void(WalletSubmission)>;
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;
};
@@ -1,13 +1,20 @@
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QHostAddress>
#include <QNetworkAccessManager>
#include <QSettings>
#include <QSignalSpy>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTemporaryDir>
#include <QTimer>
#include <QtTest>
#include <memory>
#include <utility>
#include "FakeWalletProvider.h"
#include "LogosWalletProvider.h"
#include "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<WalletAccountPresentation> 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<QTimer*>();
QVERIFY(timer);
QVERIFY(timer->isActive());
controller.disconnect();
QVERIFY(!timer->isActive());
finished.clear();
timer->setInterval(1);
controller.start();
QTest::qWait(50);
QVERIFY(!timer->isActive());
QCOMPARE(finished.count(), 0);
settings.clear();
}
void LogosWalletProviderTest::completedAsyncSnapshotReleasesCallback()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
bool completed = false;
std::weak_ptr<int> callbackLifetime;
{
auto lifetime = std::make_shared<int>(1);
callbackLifetime = lifetime;
provider.snapshotAsync(true,
[lifetime = std::move(lifetime), &completed](WalletSnapshot snapshot) {
QVERIFY(snapshot.ok());
completed = true;
});
}
QVERIFY(completed);
QVERIFY(callbackLifetime.expired());
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
}
void LogosWalletProviderTest::deferredCallbacksIgnoreDestroyedController()
{
const QString settingsApplication = QStringLiteral("WalletDestroyedCallbackTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
{
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
QVERIFY(controller->open());
}
provider.finishConnect();
provider.deferAsync = false;
{
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
QVERIFY(controller->open());
provider.deferAsync = true;
controller->refresh();
}
provider.finishSnapshot();
settings.clear();
}
void LogosWalletProviderTest::newerReachabilityResultWins()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityOrderTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
QTcpServer firstServer;
QTcpServer secondServer;
QVERIFY(firstServer.listen(QHostAddress::LocalHost));
QVERIFY(secondServer.listen(QHostAddress::LocalHost));
const QString firstEndpoint = QStringLiteral("http://127.0.0.1:%1")
.arg(firstServer.serverPort());
const QString secondEndpoint = QStringLiteral("http://127.0.0.1:%1")
.arg(secondServer.serverPort());
FakeWalletProvider provider;
provider.connectResult.snapshot.sequencerAddress = firstEndpoint;
WalletController controller(provider, settingsApplication);
auto* network = controller.findChild<QNetworkAccessManager*>();
QVERIFY(network);
QSignalSpy finished(network, &QNetworkAccessManager::finished);
QVERIFY(controller.open());
QTRY_VERIFY(firstServer.hasPendingConnections());
QTcpSocket* first = firstServer.nextPendingConnection();
QVERIFY(first);
provider.createAccountResult.accountId = ACCOUNT_B;
provider.createAccountResult.snapshot.sequencerAddress = secondEndpoint;
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
QTRY_VERIFY(secondServer.hasPendingConnections());
QTcpSocket* second = secondServer.nextPendingConnection();
QVERIFY(second);
second->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
second->disconnectFromHost();
QTRY_COMPARE(finished.size(), 1);
QVERIFY(controller.state().sequencerReachable);
first->disconnectFromHost();
QTRY_COMPARE(finished.size(), 2);
QVERIFY(controller.state().sequencerReachable);
settings.clear();
}
void LogosWalletProviderTest::coalescesReachabilityChecksForSameEndpoint()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityCoalesceTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
QTcpServer server;
QVERIFY(server.listen(QHostAddress::LocalHost));
const QString endpoint = QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort());
FakeWalletProvider provider;
provider.connectResult.snapshot.sequencerAddress = endpoint;
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QTRY_VERIFY(server.hasPendingConnections());
QTcpSocket* request = server.nextPendingConnection();
QVERIFY(request);
provider.createAccountResult.accountId = ACCOUNT_B;
provider.createAccountResult.snapshot.sequencerAddress = endpoint;
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
QTest::qWait(50);
QVERIFY(!server.hasPendingConnections());
request->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
request->disconnectFromHost();
settings.clear();
}
void LogosWalletProviderTest::controllerReportsPartialWalletCreation()
{
const QString settingsApplication = QStringLiteral("WalletPartialCreationTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
provider.createWalletResult.failure = WalletFailure::SaveFailed;
WalletController controller(provider, settingsApplication);
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QVERIFY(!controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("error"));
QCOMPARE(controller.state().syncError, QStringLiteral("save_failed"));
settings.clear();
}
QTEST_GUILESS_MAIN(LogosWalletProviderTest)
#include "LogosWalletProviderTest.moc"
@@ -7,6 +7,7 @@
#include <QVariantList>
#include <functional>
#include <utility>
class LogosAPI;
@@ -32,6 +33,10 @@ public:
int listCalls = 0;
int publicReadCalls = 0;
int submitCalls = 0;
bool deferPublicAccountCreation = false;
bool deferSubmission = false;
std::function<void(QString)> pendingPublicAccountCreation;
std::function<void(QString)> pendingSubmission;
QString openedConfig;
QString openedStorage;
QString createdConfig;
@@ -77,6 +82,17 @@ public:
QString create_account_public() { return publicAccountId; }
QString create_account_private() { return privateAccountId; }
void create_account_publicAsync(std::function<void(QString)> callback)
{
if (deferPublicAccountCreation)
pendingPublicAccountCreation = std::move(callback);
else
callback(create_account_public());
}
void create_account_privateAsync(std::function<void(QString)> callback)
{
callback(create_account_private());
}
int get_last_synced_block() const { return lastSyncedBlock; }
int get_current_block_height() const { return currentBlockHeight; }
@@ -150,6 +166,38 @@ public:
submittedProgramId = programId;
return transactionResponse;
}
void send_generic_public_transactionAsync(
const QStringList& accountIds,
const QVariantList& signingRequirements,
const QVariant& instruction,
const QString& programId,
std::function<void(QString)> callback)
{
++submitCalls;
submittedAccountIds = accountIds;
submittedSigningRequirements = signingRequirements;
submittedInstruction = instruction;
submittedProgramId = programId;
if (deferSubmission)
pendingSubmission = std::move(callback);
else
callback(transactionResponse);
}
void finishPublicAccountCreation()
{
auto callback = std::move(pendingPublicAccountCreation);
if (callback)
callback(publicAccountId);
}
void finishSubmission()
{
auto callback = std::move(pendingSubmission);
if (callback)
callback(transactionResponse);
}
};
struct LogosModules {
@@ -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
}
}
}
@@ -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")
@@ -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) {
@@ -35,6 +35,9 @@ public:
AccountReadsCallback callback;
};
QVector<PendingPublicAccountRead> 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<WalletAccountRead> accountReads(const QStringList& accountIds) const
{