feat(wallet): humanize shared wallet experience

Consolidate wallet account decoding and portfolio handling around the shared Rust IDL decoder. Simplify AMM wallet integration, remove obsolete caches and network plumbing, and cover account selection and live flows.
This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-21 17:38:45 -03:00
parent 62d133e909
commit 8c3e6fccfe
64 changed files with 6268 additions and 27501 deletions
+141 -3
View File
@@ -8,6 +8,8 @@ endif()
option(LOGOS_WALLET_BUILD_QML "Build the Logos.Wallet QML module" ON)
option(LOGOS_WALLET_BUILD_ACCESS "Build the generated-SDK wallet adapter" ON)
set(LOGOS_WALLET_GENERATED_DIR "" CACHE PATH "Path to generated Logos SDK sources")
set(LOGOS_WALLET_IDL_DECODER_LIBRARY "" CACHE FILEPATH
"wallet_idl_decoder library required by logos_wallet_access")
if(LOGOS_WALLET_BUILD_ACCESS
AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/logos_sdk.h"
@@ -24,6 +26,28 @@ endif()
set(CMAKE_AUTOMOC ON)
if(LOGOS_WALLET_BUILD_ACCESS)
if(NOT LOGOS_WALLET_IDL_DECODER_LIBRARY)
find_library(LOGOS_WALLET_IDL_DECODER_LIBRARY
NAMES wallet_idl_decoder
HINTS "$ENV{LOGOS_EXT_ROOT_WALLET_IDL_DECODER}/lib"
)
endif()
if(NOT LOGOS_WALLET_IDL_DECODER_INCLUDE_DIR)
find_path(LOGOS_WALLET_IDL_DECODER_INCLUDE_DIR
NAMES wallet_idl_decoder.h
HINTS
"$ENV{LOGOS_EXT_ROOT_WALLET_IDL_DECODER}/include"
"${CMAKE_SOURCE_DIR}/include"
"${CMAKE_SOURCE_DIR}/lib"
"${CMAKE_CURRENT_SOURCE_DIR}/../../../tools/wallet-idl-decoder/include"
)
endif()
if(NOT LOGOS_WALLET_IDL_DECODER_LIBRARY
OR NOT LOGOS_WALLET_IDL_DECODER_INCLUDE_DIR)
message(FATAL_ERROR
"logos_wallet_access requires wallet_idl_decoder library and headers"
)
endif()
add_library(logos_wallet_access STATIC
src/WalletProvider.h
src/WalletProvider.cpp
@@ -33,6 +57,16 @@ if(LOGOS_WALLET_BUILD_ACCESS)
src/WalletAccountModel.cpp
src/WalletController.h
src/WalletController.cpp
src/WalletIdlDecoder.h
src/WalletIdlDecoder.cpp
src/SequencerNetworkContext.h
src/SequencerNetworkContext.cpp
src/SequencerNetworkSettings.h
src/SequencerNetworkSettings.cpp
src/SequencerIdentityProbe.h
src/SequencerIdentityProbe.cpp
src/WalletPortfolioService.h
src/WalletPortfolioService.cpp
)
set_target_properties(logos_wallet_access PROPERTIES
AUTOMOC ON
@@ -45,10 +79,18 @@ if(LOGOS_WALLET_BUILD_ACCESS)
PRIVATE
"${LOGOS_WALLET_GENERATED_DIR}"
"${LOGOS_WALLET_GENERATED_DIR}/include"
"${LOGOS_WALLET_IDL_DECODER_INCLUDE_DIR}"
)
target_link_libraries(logos_wallet_access
PUBLIC Qt6::Core
PRIVATE Qt6::Network
PRIVATE
Qt6::Network
"${LOGOS_WALLET_IDL_DECODER_LIBRARY}"
)
qt_add_resources(logos_wallet_access logos_wallet_access_network_data
PREFIX "/wallet"
FILES
config/networks.json
)
endif()
@@ -59,13 +101,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/ProgramAccountSelector.qml
qml/TransactionConfirmationDialog.qml
@@ -160,6 +202,94 @@ if(BUILD_TESTING)
)
add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test)
if(LOGOS_WALLET_BUILD_ACCESS)
add_executable(logos_wallet_idl_decoder_link_test
tests/cpp/WalletIdlDecoderLinkTest.cpp
)
target_compile_features(logos_wallet_idl_decoder_link_test PRIVATE cxx_std_17)
target_link_libraries(logos_wallet_idl_decoder_link_test PRIVATE
Qt6::Core
Qt6::Test
logos_wallet_access
)
add_test(NAME logos_wallet_idl_decoder_link
COMMAND logos_wallet_idl_decoder_link_test)
endif()
add_executable(logos_wallet_sequencer_network_context_test
tests/cpp/SequencerNetworkContextTest.cpp
src/SequencerNetworkContext.h
src/SequencerNetworkContext.cpp
)
set_target_properties(logos_wallet_sequencer_network_context_test PROPERTIES AUTOMOC ON)
target_compile_features(logos_wallet_sequencer_network_context_test PRIVATE cxx_std_17)
target_include_directories(logos_wallet_sequencer_network_context_test PRIVATE src)
target_link_libraries(logos_wallet_sequencer_network_context_test PRIVATE
Qt6::Core
Qt6::Test
)
add_test(NAME logos_wallet_sequencer_network_context
COMMAND logos_wallet_sequencer_network_context_test)
add_executable(logos_wallet_sequencer_network_settings_test
tests/cpp/SequencerNetworkSettingsTest.cpp
src/SequencerNetworkContext.h
src/SequencerNetworkContext.cpp
src/SequencerNetworkSettings.h
src/SequencerNetworkSettings.cpp
)
set_target_properties(logos_wallet_sequencer_network_settings_test PROPERTIES AUTOMOC ON)
target_compile_features(logos_wallet_sequencer_network_settings_test PRIVATE cxx_std_17)
target_include_directories(logos_wallet_sequencer_network_settings_test PRIVATE src)
target_link_libraries(logos_wallet_sequencer_network_settings_test PRIVATE
Qt6::Core
Qt6::Test
)
qt_add_resources(logos_wallet_sequencer_network_settings_test
logos_wallet_access_network_data
PREFIX "/wallet"
FILES
config/networks.json
)
add_test(NAME logos_wallet_sequencer_network_settings
COMMAND logos_wallet_sequencer_network_settings_test)
add_executable(logos_wallet_sequencer_identity_probe_test
tests/cpp/SequencerIdentityProbeTest.cpp
src/SequencerIdentityProbe.h
src/SequencerIdentityProbe.cpp
src/SequencerNetworkContext.h
src/SequencerNetworkContext.cpp
)
set_target_properties(logos_wallet_sequencer_identity_probe_test PROPERTIES AUTOMOC ON)
target_compile_features(logos_wallet_sequencer_identity_probe_test PRIVATE cxx_std_17)
target_include_directories(logos_wallet_sequencer_identity_probe_test PRIVATE src)
target_link_libraries(logos_wallet_sequencer_identity_probe_test PRIVATE
Qt6::Core
Qt6::Network
Qt6::Test
)
add_test(NAME logos_wallet_sequencer_identity_probe
COMMAND logos_wallet_sequencer_identity_probe_test)
add_executable(logos_wallet_portfolio_service_test
tests/cpp/WalletPortfolioServiceTest.cpp
src/WalletPortfolioService.h
src/WalletPortfolioService.cpp
src/WalletProvider.cpp
)
set_target_properties(logos_wallet_portfolio_service_test PROPERTIES AUTOMOC ON)
target_compile_features(logos_wallet_portfolio_service_test PRIVATE cxx_std_17)
target_include_directories(logos_wallet_portfolio_service_test PRIVATE
src
)
target_link_libraries(logos_wallet_portfolio_service_test PRIVATE
Qt6::Core
Qt6::Test
)
add_test(NAME logos_wallet_portfolio_service
COMMAND logos_wallet_portfolio_service_test)
if(LOGOS_WALLET_BUILD_QML)
find_package(Qt6 6.8 REQUIRED COMPONENTS QuickTest)
add_executable(logos_wallet_qml_test tests/qml/main.cpp)
@@ -169,8 +299,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()
+5
View File
@@ -0,0 +1,5 @@
{
"testnet": {
"checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a"
}
}
@@ -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
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -5,53 +5,127 @@ import QtQuick.Layouts
ItemDelegate {
id: root
required property int index
required property string name
required property string alias
required property string address
required property string balance
required property bool isPublic
required property string displayAddress
required property string kind
required property string section
required property string programName
required property string accountType
required property string decodedData
required property string visibility
required property bool canBePrimary
required property bool isPrimary
signal copyRequested(string text)
signal makePrimaryRequested(string address)
signal renameRequested(string address, string alias)
leftPadding: 12
rightPadding: 8
topPadding: 10
bottomPadding: 10
enabled: root.section !== "hidden"
Accessible.name: root.isPrimary
? qsTr("%1, primary account").arg(root.name)
: root.name
Accessible.name: qsTr("%1, balance %2").arg(root.name).arg(root.balance || "0")
function kindLabel() {
if (root.kind === "user")
return qsTr("User")
if (root.kind === "private")
return qsTr("Account")
if (root.accountType.length > 0)
return root.accountType
return root.kind === "unknown" ? qsTr("Unknown") : qsTr("Program")
}
background: Rectangle {
color: root.highlighted || root.hovered ? "#27272a" : "#18181b"
radius: 6
border.width: root.activeFocus ? 1 : 0
border.color: "#f26a21"
color: root.isPrimary || root.hovered ? "#27272a" : "#18181b"
radius: 8
border.width: root.activeFocus || root.isPrimary ? 1 : 0
border.color: root.isPrimary ? "#f59e0b" : "#52525b"
}
contentItem: ColumnLayout {
spacing: 6
spacing: 7
RowLayout {
Layout.fillWidth: true
spacing: 8
spacing: 7
Label {
Layout.fillWidth: true
text: root.name
color: "#f4f4f5"
color: "#fafafa"
font.bold: true
elide: Text.ElideRight
}
Label {
visible: root.isPrimary
text: qsTr("Primary")
color: "#fbbf24"
font.pixelSize: 11
font.bold: true
}
Label {
text: root.isPublic ? qsTr("Public") : qsTr("Private")
text: root.kindLabel()
color: "#a1a1aa"
font.pixelSize: 11
}
Item { Layout.fillWidth: true }
Label {
text: root.visibility === "private" ? qsTr("Private") : qsTr("Public")
color: root.visibility === "private" ? "#c4b5fd" : "#93c5fd"
font.pixelSize: 11
}
}
Label {
objectName: "walletProgramName"
visible: root.section === "advanced" && root.programName.length > 0
Layout.fillWidth: true
text: qsTr("Program: %1").arg(root.programName)
color: "#a1a1aa"
font.pixelSize: 11
elide: Text.ElideRight
}
ColumnLayout {
visible: root.section === "advanced" && root.decodedData.length > 0
Layout.fillWidth: true
spacing: 4
Label {
text: root.balance.length > 0 ? root.balance : "-"
color: "#f4f4f5"
font.bold: true
objectName: "walletDecodedDataLabel"
text: qsTr("Decoded data")
color: "#a1a1aa"
font.pixelSize: 11
}
Rectangle {
objectName: "walletDecodedDataBox"
Layout.fillWidth: true
implicitHeight: decodedDataText.implicitHeight + 16
color: "#18181b"
radius: 6
border.width: 1
border.color: "#3f3f46"
Text {
id: decodedDataText
objectName: "walletDecodedData"
anchors.fill: parent
anchors.margins: 8
text: root.decodedData
color: "#d4d4d8"
font.family: "monospace"
font.pixelSize: 10
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
}
}
}
@@ -61,17 +135,45 @@ ItemDelegate {
Label {
Layout.fillWidth: true
text: root.address
color: "#a1a1aa"
text: root.displayAddress
color: "#71717a"
font.family: "monospace"
font.pixelSize: 11
elide: Text.ElideMiddle
}
CopyButton {
visible: root.address.length > 0
onCopyRequested: root.copyRequested(root.address)
visible: root.displayAddress.length > 0
copyText: root.displayAddress
copyLabel: qsTr("Copy address")
}
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Button {
objectName: "walletRenameButton"
text: qsTr("Rename")
flat: true
onClicked: root.renameRequested(root.address, root.alias)
}
Item { Layout.fillWidth: true }
Button {
objectName: "walletMakePrimaryButton"
visible: root.canBePrimary && !root.isPrimary
text: qsTr("Make primary")
flat: true
onClicked: root.makePrimaryRequested(root.address)
}
}
}
onClicked: {
if (root.canBePrimary && !root.isPrimary)
root.makePrimaryRequested(root.address)
}
}
+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()
+386 -94
View File
@@ -1,11 +1,13 @@
#include "LogosWalletProvider.h"
#include <QByteArray>
#include <algorithm>
#include <QDir>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QTimer>
#include <QVariantList>
#include <QVariantMap>
@@ -74,6 +76,103 @@ WalletCreation failedCreation(WalletFailure failure)
creation.snapshot.failure = failure;
return creation;
}
WalletAccountRead parsePublicAccount(const QString& accountId, const QString& payload)
{
WalletAccountRead read;
read.accountId = accountId;
if (!isHex(accountId, 64))
return read;
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
return read;
const QJsonObject account = document.object();
const QString owner = account.value(QStringLiteral("program_owner")).toString();
const QString balance = account.value(QStringLiteral("balance")).toString();
const QString nonce = account.value(QStringLiteral("nonce")).toString();
const QString data = account.value(QStringLiteral("data")).toString();
if (!isHex(owner, 64)
|| !isHex(balance, 32)
|| !isHex(nonce, 32)
|| data.size() % 2 != 0
|| !isHex(data, data.size())) {
return read;
}
read.status = QStringLiteral("ok");
read.programOwner = owner;
read.balanceHex = balance;
read.nonceHex = nonce;
read.dataHex = data;
return read;
}
void applyPublicRead(WalletAccount& account, const WalletAccountRead& read)
{
account.readStatus = read.status;
account.programOwner = read.programOwner;
account.dataHex = read.dataHex;
}
bool encodeTransaction(const WalletTransaction& transaction,
QVariantList* signingRequirements,
QByteArray* instruction)
{
if (!isHex(transaction.programId, 64)
|| transaction.accountIds.size() != transaction.signingRequirements.size()) {
return false;
}
for (const QString& accountId : transaction.accountIds) {
if (!isHex(accountId, 64))
return false;
}
signingRequirements->reserve(transaction.signingRequirements.size());
for (bool required : transaction.signingRequirements)
signingRequirements->append(required);
instruction->reserve(
static_cast<int>(transaction.instruction.size() * sizeof(quint32)));
for (const quint32 word : transaction.instruction) {
instruction->append(static_cast<char>(word & 0xff));
instruction->append(static_cast<char>((word >> 8) & 0xff));
instruction->append(static_cast<char>((word >> 16) & 0xff));
instruction->append(static_cast<char>((word >> 24) & 0xff));
}
return true;
}
WalletSubmission parseSubmission(const QString& response)
{
WalletSubmission submission;
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
}
const QJsonObject result = document.object();
const QJsonValue success = result.value(QStringLiteral("success"));
const QJsonValue error = result.value(QStringLiteral("error"));
const QString hash = result.value(QStringLiteral("tx_hash")).toString();
const bool emptyError = error.isUndefined()
|| error.isNull()
|| (error.isString() && error.toString().isEmpty());
if (!success.isBool()
|| !success.toBool()
|| !emptyError
|| !isHex(hash, 64, false)) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
}
submission.nativeHash = hash.toLower();
return submission;
}
}
struct LogosWalletProvider::Impl {
@@ -103,12 +202,14 @@ LogosWalletProvider::LogosWalletProvider(LogosModules* logos)
LogosWalletProvider::~LogosWalletProvider()
{
++m_generation;
if (m_connected)
save();
}
WalletSession LogosWalletProvider::connect(const WalletPaths& paths)
{
++m_generation;
clearSnapshot();
if (!m_impl->logos)
return failedSession(WalletFailure::WalletUnavailable);
@@ -131,9 +232,72 @@ WalletSession LogosWalletProvider::connect(const WalletPaths& paths)
return session;
}
void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback callback)
{
clearSnapshot();
const quint64 generation = ++m_generation;
if (!m_impl->logos) {
QTimer::singleShot(0, [callback = std::move(callback)]() mutable {
callback(failedSession(WalletFailure::WalletUnavailable));
});
return;
}
auto finishOpen = [this, generation, callback = std::move(callback)](
bool adopted, WalletFailure failure) mutable {
if (generation != m_generation)
return;
if (failure != WalletFailure::None) {
callback(failedSession(failure));
return;
}
m_connected = true;
loadSnapshotAsync(generation,
[this, generation, adopted, callback = std::move(callback)](
WalletSnapshot snapshot) mutable {
if (generation != m_generation)
return;
WalletSession session;
session.adopted = adopted;
session.failure = snapshot.failure;
session.snapshot = std::move(snapshot);
callback(std::move(session));
});
};
auto openStored = [this, generation, paths, finishOpen]() mutable {
if (generation != m_generation)
return;
if (!QFileInfo::exists(paths.storage)) {
finishOpen(false, WalletFailure::WalletMissing);
return;
}
m_impl->logos->logos_execution_zone.openAsync(
paths.config, paths.storage,
[this, generation, finishOpen](int result) mutable {
if (generation != m_generation)
return;
finishOpen(false, result == WALLET_FFI_SUCCESS
? WalletFailure::None : WalletFailure::OpenFailed);
});
};
m_impl->logos->logos_execution_zone.get_sequencer_addrAsync(
[this, generation, finishOpen, openStored](QString address) mutable {
if (generation != m_generation)
return;
if (!address.isEmpty()) {
finishOpen(true, WalletFailure::None);
return;
}
openStored();
});
}
WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths,
const QString& password)
{
++m_generation;
clearSnapshot();
if (!m_impl->logos)
return failedCreation(WalletFailure::WalletUnavailable);
@@ -158,8 +322,6 @@ WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths,
return creation;
}
creation.snapshot = snapshot(true);
creation.failure = creation.snapshot.failure;
return creation;
}
@@ -181,6 +343,26 @@ WalletSnapshot LogosWalletProvider::snapshot(bool forceRefresh)
return result;
}
void LogosWalletProvider::snapshotAsync(bool forceRefresh, SnapshotCallback callback)
{
if (m_snapshotReady && !forceRefresh) {
const WalletSnapshot snapshot = m_snapshot;
QTimer::singleShot(0, [callback = std::move(callback), snapshot]() mutable {
callback(snapshot);
});
return;
}
if (!m_connected) {
WalletSnapshot snapshot;
snapshot.failure = WalletFailure::WalletUnavailable;
QTimer::singleShot(0, [callback = std::move(callback), snapshot]() mutable {
callback(snapshot);
});
return;
}
loadSnapshotAsync(++m_generation, std::move(callback));
}
void LogosWalletProvider::clearSnapshot()
{
m_snapshot = {};
@@ -209,45 +391,49 @@ WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic)
if (isPublic)
creation.publicAccount = readPublicAccount(creation.accountId);
clearSnapshot();
creation.snapshot = snapshot(true);
if (m_snapshotReady) {
WalletAccount account;
account.address = creation.accountId;
account.displayAddress =
m_impl->logos->logos_execution_zone.account_id_to_base58(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;
}
WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const
{
WalletAccountRead read;
read.accountId = accountId;
if (!m_impl->logos || !isHex(accountId, 64))
return read;
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(
m_impl->logos->logos_execution_zone.get_account_public(accountId).toUtf8(),
&parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
return read;
const QJsonObject account = document.object();
const QString owner = account.value(QStringLiteral("program_owner")).toString();
const QString balance = account.value(QStringLiteral("balance")).toString();
const QString nonce = account.value(QStringLiteral("nonce")).toString();
const QString data = account.value(QStringLiteral("data")).toString();
if (!isHex(owner, 64)
|| !isHex(balance, 32)
|| !isHex(nonce, 32)
|| data.size() % 2 != 0
|| !isHex(data, data.size())) {
return read;
}
read.status = QStringLiteral("ok");
read.programOwner = owner;
read.balanceHex = balance;
read.nonceHex = nonce;
read.dataHex = data;
return read;
return WalletAccountRead { accountId };
return parsePublicAccount(
accountId,
m_impl->logos->logos_execution_zone.get_account_public(accountId));
}
WalletSubmission LogosWalletProvider::submitPublicTransaction(
@@ -258,73 +444,25 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction(
submission.failure = WalletFailure::WalletUnavailable;
return submission;
}
if (!isHex(transaction.programId, 64)
|| transaction.accountIds.size() != transaction.signingRequirements.size()) {
QVariantList signingRequirements;
QByteArray instruction;
if (!encodeTransaction(transaction, &signingRequirements, &instruction)) {
submission.failure = WalletFailure::InvalidRequest;
return submission;
}
for (const QString& accountId : transaction.accountIds) {
if (!isHex(accountId, 64)) {
submission.failure = WalletFailure::InvalidRequest;
return submission;
}
}
QVariantList signingRequirements;
signingRequirements.reserve(transaction.signingRequirements.size());
for (bool required : transaction.signingRequirements)
signingRequirements.append(required);
// `send_generic_public_transaction`'s `instruction` param is a byte string
// (bstr). Passing a QVariantList<u32> makes the module's QtRO glue mangle it,
// so the guest reads a garbage Instruction variant. Send the little-endian
// bytes of the u32 words instead — same encoding the AMM swap path uses.
// See docs/amm-swap-qtro-serialization-bug.md.
QByteArray instructionBytes;
instructionBytes.reserve(
static_cast<int>(transaction.instruction.size() * sizeof(quint32)));
for (const quint32 word : transaction.instruction) {
instructionBytes.append(static_cast<char>(word & 0xff));
instructionBytes.append(static_cast<char>((word >> 8) & 0xff));
instructionBytes.append(static_cast<char>((word >> 16) & 0xff));
instructionBytes.append(static_cast<char>((word >> 24) & 0xff));
}
const QString response =
m_impl->logos->logos_execution_zone.send_generic_public_transaction(
transaction.accountIds,
signingRequirements,
QVariant::fromValue(instructionBytes),
QVariant::fromValue(instruction),
transaction.programId);
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;
return parseSubmission(response);
}
void LogosWalletProvider::disconnect()
{
++m_generation;
if (m_connected)
save();
clearSnapshot();
@@ -335,9 +473,8 @@ bool LogosWalletProvider::sharedWalletIsOpen() const
{
if (!m_impl->logos)
return false;
if (!m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty())
return true;
return !m_impl->logos->logos_execution_zone.list_accounts().isEmpty();
// A live wallet always has a configured, non-empty sequencer URL.
return !m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty();
}
WalletSnapshot LogosWalletProvider::loadSnapshot()
@@ -368,24 +505,179 @@ WalletSnapshot LogosWalletProvider::loadSnapshot()
WalletAccount account;
account.address = address;
account.displayAddress =
m_impl->logos->logos_execution_zone.account_id_to_base58(address);
account.isPublic = entry.value(QStringLiteral("is_public"), true).toBool();
if (account.isPublic) {
const WalletAccountRead read = readPublicAccount(address);
result.publicAccountReads.append(read);
applyPublicRead(account, read);
account.balance = read.ok()
? littleEndianU128ToDecimal(read.balanceHex)
: m_impl->logos->logos_execution_zone.get_balance(address, true);
} else {
account.readStatus = QStringLiteral("private");
account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false);
}
result.accounts.append(account);
}
if (!save())
result.failure = WalletFailure::SaveFailed;
return result;
}
void LogosWalletProvider::loadSnapshotAsync(quint64 generation, SnapshotCallback callback)
{
if (!m_impl->logos || generation != m_generation)
return;
m_impl->logos->logos_execution_zone.get_current_block_heightAsync(
[this, generation, callback = std::move(callback)](int currentHeight) mutable {
if (generation != m_generation)
return;
auto afterSync = [this, generation, currentHeight,
callback = std::move(callback)](int syncResult) mutable {
if (generation != m_generation)
return;
if (syncResult != WALLET_FFI_SUCCESS) {
WalletSnapshot failed;
failed.failure = WalletFailure::ReadFailed;
callback(std::move(failed));
return;
}
m_impl->logos->logos_execution_zone.get_last_synced_blockAsync(
[this, generation, currentHeight,
callback = std::move(callback)](int lastSynced) mutable {
if (generation != m_generation)
return;
m_impl->logos->logos_execution_zone.get_sequencer_addrAsync(
[this, generation, currentHeight, lastSynced,
callback = std::move(callback)](QString address) mutable {
if (generation != m_generation)
return;
m_impl->logos->logos_execution_zone.list_accountsAsync(
[this, generation, currentHeight, lastSynced,
address = std::move(address),
callback = std::move(callback)](
QVariantList entries) mutable {
if (generation != m_generation)
return;
struct SnapshotState {
WalletSnapshot snapshot;
QVector<WalletAccountRead> publicReads;
QVector<bool> publicFlags;
qsizetype remaining = 0;
SnapshotCallback callback;
};
auto state = std::make_shared<SnapshotState>();
state->snapshot.currentBlockHeight = static_cast<quint64>(
qMax(0, currentHeight));
state->snapshot.lastSyncedBlock = static_cast<quint64>(
qMax(0, lastSynced));
state->snapshot.sequencerAddress = std::move(address);
state->snapshot.accounts.resize(entries.size());
state->publicReads.resize(entries.size());
state->publicFlags.resize(entries.size());
state->remaining = entries.size();
state->callback = std::move(callback);
for (qsizetype index = 0; index < entries.size(); ++index) {
const QVariantMap entry = entries.at(index).toMap();
const QString accountId = entry
.value(QStringLiteral("account_id")).toString();
if (entry.isEmpty() || !isHex(accountId, 64)) {
state->snapshot.failure = WalletFailure::ReadFailed;
state->callback(std::move(state->snapshot));
return;
}
state->snapshot.accounts[index] = WalletAccount {
accountId,
{},
entry.value(QStringLiteral("is_public"), true).toBool(),
};
state->snapshot.accounts[index].displayAddress =
m_impl->logos->logos_execution_zone
.account_id_to_base58(accountId);
state->publicFlags[index] =
state->snapshot.accounts.at(index).isPublic;
}
auto finishOne = std::make_shared<std::function<void()>>();
*finishOne = [this, generation, state]() mutable {
if (generation != m_generation || --state->remaining > 0)
return;
for (qsizetype index = 0;
index < state->publicReads.size(); ++index) {
if (state->publicFlags.at(index))
state->snapshot.publicAccountReads.append(
state->publicReads.at(index));
}
if (state->snapshot.ok()) {
m_snapshot = state->snapshot;
m_snapshotReady = true;
}
state->callback(std::move(state->snapshot));
};
if (entries.isEmpty()) {
state->remaining = 1;
(*finishOne)();
return;
}
for (qsizetype index = 0; index < entries.size(); ++index) {
const WalletAccount account = state->snapshot.accounts.at(index);
if (!account.isPublic) {
m_impl->logos->logos_execution_zone.get_balanceAsync(
account.address, false,
[state, finishOne, index](QString balance) {
state->snapshot.accounts[index].balance =
std::move(balance);
(*finishOne)();
});
continue;
}
m_impl->logos->logos_execution_zone.get_account_publicAsync(
account.address,
[this, state, finishOne, index,
accountId = account.address](QString payload) {
const WalletAccountRead read =
parsePublicAccount(accountId, payload);
state->publicReads[index] = read;
applyPublicRead(
state->snapshot.accounts[index], read);
if (read.ok()) {
state->snapshot.accounts[index].balance =
littleEndianU128ToDecimal(read.balanceHex);
(*finishOne)();
return;
}
m_impl->logos->logos_execution_zone.get_balanceAsync(
accountId, true,
[state, finishOne, index](QString balance) {
state->snapshot.accounts[index].balance =
std::move(balance);
(*finishOne)();
});
});
}
});
});
});
};
if (currentHeight > 0) {
m_impl->logos->logos_execution_zone.sync_to_blockAsync(
currentHeight, std::move(afterSync));
} else {
afterSync(WALLET_FFI_SUCCESS);
}
});
}
bool LogosWalletProvider::save() const
{
return m_impl->logos
+7 -1
View File
@@ -2,21 +2,25 @@
#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);
~LogosWalletProvider() override;
WalletSession connect(const WalletPaths& paths) override;
void connectAsync(const WalletPaths& paths, SessionCallback callback) override;
WalletCreation createWallet(const WalletPaths& paths,
const QString& password) override;
WalletSnapshot snapshot(bool forceRefresh = false) override;
void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override;
void clearSnapshot() override;
WalletAccountCreation createAccount(bool isPublic) override;
WalletAccountRead readPublicAccount(const QString& accountId) const override;
@@ -27,6 +31,7 @@ public:
private:
bool sharedWalletIsOpen() const;
WalletSnapshot loadSnapshot();
void loadSnapshotAsync(quint64 generation, SnapshotCallback callback);
bool save() const;
struct Impl;
@@ -34,4 +39,5 @@ private:
WalletSnapshot m_snapshot;
bool m_snapshotReady = false;
bool m_connected = false;
quint64 m_generation = 0;
};
@@ -0,0 +1,285 @@
#include "SequencerIdentityProbe.h"
#include <algorithm>
#include <utility>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QTimer>
#include <QVariant>
namespace {
constexpr int REQUEST_TIMEOUT_MILLISECONDS = 4000;
constexpr int INITIAL_RETRY_DELAY_MILLISECONDS = 250;
constexpr int MAX_RETRY_DELAY_MILLISECONDS = 4000;
constexpr qsizetype CHECKPOINT_BLOCK_HASH_OFFSET = 40;
constexpr qsizetype CHECKPOINT_BLOCK_HASH_SIZE = 32;
QByteArray jsonRpcBody(const QString& method, const QJsonArray& params)
{
return QJsonDocument(QJsonObject {
{ QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
{ QStringLiteral("id"), 1 },
{ QStringLiteral("method"), method },
{ QStringLiteral("params"), params },
}).toJson(QJsonDocument::Compact);
}
bool hasSuccessStatus(const QVariant& status)
{
if (!status.isValid())
return false;
const int code = status.toInt();
return code >= 200 && code < 300;
}
}
SequencerIdentityProbe::SequencerIdentityProbe(QObject* parent)
: QObject(parent),
m_network(new QNetworkAccessManager(this)),
m_retryTimer(new QTimer(this))
{
m_retryTimer->setSingleShot(true);
connect(m_retryTimer, &QTimer::timeout, this, &SequencerIdentityProbe::start);
}
SequencerIdentityProbe::~SequencerIdentityProbe()
{
cancelPendingWork();
}
bool SequencerIdentityProbe::configure(SequencerNetworkContext::Configuration network,
Request request)
{
cancelPendingWork();
m_networkConfiguration = std::move(network);
m_request = std::move(request);
m_requestConfigured = isValidRequest(m_request)
&& m_context.configure(m_networkConfiguration);
if (!m_requestConfigured) {
if (m_context.isConfigured())
m_context.clearConfiguration();
emit snapshotChanged();
return false;
}
updateContextAvailability();
emit snapshotChanged();
start();
return true;
}
bool SequencerIdentityProbe::setEndpoint(QUrl endpoint)
{
Request updated = m_request;
updated.endpoint = std::move(endpoint);
if (!m_requestConfigured || !isValidRequest(updated))
return false;
if (updated.endpoint == m_request.endpoint)
return true;
m_request.endpoint = std::move(updated.endpoint);
restartContext();
return true;
}
void SequencerIdentityProbe::clearConfiguration()
{
cancelPendingWork();
m_requestConfigured = false;
m_request = {};
m_networkConfiguration = {};
m_context.clearConfiguration();
emit snapshotChanged();
}
void SequencerIdentityProbe::setSequencerAvailable(bool available)
{
if (m_sequencerAvailable == available)
return;
cancelPendingWork();
m_sequencerAvailable = available;
updateContextAvailability();
emit snapshotChanged();
start();
}
void SequencerIdentityProbe::setReachable(bool reachable)
{
if (m_reachable == reachable)
return;
cancelPendingWork();
m_reachable = reachable;
updateContextAvailability();
emit snapshotChanged();
start();
}
void SequencerIdentityProbe::start()
{
if (!m_requestConfigured
|| !isValidEndpoint(m_request.endpoint)
|| m_reply
|| m_retryTimer->isActive())
return;
const std::optional<quint64> contextGeneration = m_context.beginIdentityProbe();
if (!contextGeneration)
return;
emit snapshotChanged();
QNetworkRequest request(m_request.endpoint);
request.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/json"));
request.setTransferTimeout(REQUEST_TIMEOUT_MILLISECONDS);
QNetworkReply* reply = m_network->post(request,
jsonRpcBody(m_request.method, m_request.params));
m_reply = reply;
const quint64 requestGeneration = m_requestGeneration;
connect(reply, &QNetworkReply::finished, this,
[this, reply, contextGeneration = *contextGeneration, requestGeneration]() {
handleReply(reply, contextGeneration, requestGeneration);
});
}
bool SequencerIdentityProbe::isValidRequest(const Request& request)
{
return !request.method.trimmed().isEmpty()
&& static_cast<bool>(request.identityFromResult);
}
bool SequencerIdentityProbe::isValidEndpoint(const QUrl& endpoint)
{
return endpoint.isValid()
&& (endpoint.scheme() == QStringLiteral("http")
|| endpoint.scheme() == QStringLiteral("https"))
&& !endpoint.host().isEmpty();
}
QString SequencerIdentityProbe::stringIdentity(const QJsonValue& result)
{
return result.isString() ? result.toString() : QString();
}
QString SequencerIdentityProbe::checkpointBlockHash(const QJsonValue& result)
{
if (!result.isString())
return {};
const QByteArray block = QByteArray::fromBase64(result.toString().toLatin1());
if (block.size() < CHECKPOINT_BLOCK_HASH_OFFSET + CHECKPOINT_BLOCK_HASH_SIZE)
return {};
return QString::fromLatin1(
block.mid(CHECKPOINT_BLOCK_HASH_OFFSET, CHECKPOINT_BLOCK_HASH_SIZE).toHex());
}
void SequencerIdentityProbe::restartContext()
{
cancelPendingWork();
if (!m_context.configure(m_networkConfiguration)) {
m_requestConfigured = false;
emit snapshotChanged();
return;
}
updateContextAvailability();
emit snapshotChanged();
start();
}
void SequencerIdentityProbe::updateContextAvailability()
{
m_context.setSequencerAvailable(m_sequencerAvailable
&& isValidEndpoint(m_request.endpoint));
m_context.setReachable(m_reachable);
}
void SequencerIdentityProbe::cancelPendingWork()
{
m_retryTimer->stop();
m_nextRetryDelayMilliseconds = INITIAL_RETRY_DELAY_MILLISECONDS;
++m_requestGeneration;
if (!m_reply)
return;
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
reply->deleteLater();
}
void SequencerIdentityProbe::handleReply(QNetworkReply* reply,
quint64 contextGeneration,
quint64 requestGeneration)
{
if (m_reply == reply)
m_reply = nullptr;
if (requestGeneration != m_requestGeneration) {
reply->deleteLater();
return;
}
QString failure;
QString identity;
const QVariant status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute);
if (status.isValid() && !hasSuccessStatus(status)) {
failure = QStringLiteral("http_status");
} else if (reply->error() != QNetworkReply::NoError) {
failure = QStringLiteral("transport_error");
} else if (!hasSuccessStatus(status)) {
failure = QStringLiteral("http_status");
} else {
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(reply->readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
failure = QStringLiteral("malformed_response");
} else {
const QJsonObject response = document.object();
const QJsonValue rpcError = response.value(QStringLiteral("error"));
if ((!rpcError.isUndefined() && !rpcError.isNull())) {
failure = QStringLiteral("json_rpc_error");
} else {
const QJsonValue result = response.value(QStringLiteral("result"));
if (result.isUndefined()) {
failure = QStringLiteral("malformed_response");
} else {
identity = m_request.identityFromResult(result);
if (identity.isEmpty())
failure = QStringLiteral("malformed_response");
}
}
}
}
const bool accepted = m_context.finishIdentityProbe(contextGeneration, identity);
reply->deleteLater();
if (!accepted)
return;
emit snapshotChanged();
if (!failure.isEmpty()) {
emit probeFailed(failure);
scheduleRetry();
} else if (!m_context.isReady() && m_context.needsIdentityProbe()) {
emit probeFailed(QStringLiteral("invalid_identity"));
scheduleRetry();
} else if (m_context.isReady()) {
m_nextRetryDelayMilliseconds = INITIAL_RETRY_DELAY_MILLISECONDS;
}
}
void SequencerIdentityProbe::scheduleRetry()
{
if (!m_requestConfigured || !m_context.needsIdentityProbe() || m_retryTimer->isActive())
return;
const int delay = m_nextRetryDelayMilliseconds;
m_nextRetryDelayMilliseconds = std::min(m_nextRetryDelayMilliseconds * 2,
MAX_RETRY_DELAY_MILLISECONDS);
m_retryTimer->start(delay);
}
@@ -0,0 +1,89 @@
#pragma once
#include <functional>
#include <optional>
#include <QJsonArray>
#include <QJsonValue>
#include <QObject>
#include <QString>
#include <QUrl>
#include "SequencerNetworkContext.h"
class QNetworkAccessManager;
class QNetworkReply;
class QTimer;
// Owns the JSON-RPC lifecycle used to prove that a wallet endpoint belongs to
// a configured network. Consumers supply only their RPC method, parameters,
// and the protocol-specific extraction of an identity from `result`.
class SequencerIdentityProbe final : public QObject {
Q_OBJECT
public:
using IdentityParser = std::function<QString(const QJsonValue& result)>;
struct Request {
QUrl endpoint;
QString method;
QJsonArray params;
IdentityParser identityFromResult;
};
explicit SequencerIdentityProbe(QObject* parent = nullptr);
~SequencerIdentityProbe() override;
// Replaces both the expected network identity and RPC request. Existing
// replies are aborted before the new context can issue a probe.
bool configure(SequencerNetworkContext::Configuration network, Request request);
// An endpoint change invalidates the previous identity result, including a
// reply that may already be in flight. An invalid/empty endpoint leaves the
// configured network in network_unknown until a valid endpoint is supplied.
bool setEndpoint(QUrl endpoint);
void clearConfiguration();
void setSequencerAvailable(bool available);
void setReachable(bool reachable);
// Safe to call after every wallet/network state update. It starts a probe
// only when the context currently needs one and no retry is pending.
void start();
const SequencerNetworkSnapshot& snapshot() const { return m_context.snapshot(); }
// Common JSON-RPC result parsers. `checkpointBlockHash` decodes the
// fixed-layout base64 block response used by checkpoint probes.
static QString stringIdentity(const QJsonValue& result);
static QString checkpointBlockHash(const QJsonValue& result);
signals:
// Emitted whenever the externally visible network state changes.
void snapshotChanged();
// A transient RPC failure was rejected and a retry may be scheduled.
void probeFailed(const QString& reason);
private:
static bool isValidRequest(const Request& request);
static bool isValidEndpoint(const QUrl& endpoint);
void restartContext();
void updateContextAvailability();
void cancelPendingWork();
void handleReply(QNetworkReply* reply, quint64 contextGeneration,
quint64 requestGeneration);
void scheduleRetry();
SequencerNetworkContext m_context;
SequencerNetworkContext::Configuration m_networkConfiguration;
Request m_request;
QNetworkAccessManager* m_network;
QNetworkReply* m_reply = nullptr;
QTimer* m_retryTimer;
quint64 m_requestGeneration = 0;
int m_nextRetryDelayMilliseconds = 250;
bool m_requestConfigured = false;
bool m_sequencerAvailable = false;
bool m_reachable = false;
};
@@ -0,0 +1,133 @@
#include "SequencerNetworkContext.h"
#include <utility>
namespace {
bool isLowerHex(const QString& value, int size)
{
if (value.size() != size)
return false;
for (const QChar character : value) {
const bool digit = character >= QLatin1Char('0')
&& character <= QLatin1Char('9');
if (!digit && (character < QLatin1Char('a') || character > QLatin1Char('f')))
return false;
}
return true;
}
}
bool SequencerNetworkContext::configure(Configuration configuration)
{
clearConfiguration();
m_snapshot.id = std::move(configuration.id);
if (!isValidIdentity(configuration.expectedIdentity))
return false;
m_expectedIdentity = std::move(configuration.expectedIdentity);
m_fingerprintPrefix = std::move(configuration.fingerprintPrefix);
m_configured = true;
clearIdentity(QStringLiteral("network_unknown"));
return true;
}
void SequencerNetworkContext::clearConfiguration()
{
invalidateProbe();
m_snapshot = {};
m_snapshot.status = QStringLiteral("config_missing");
m_expectedIdentity.clear();
m_fingerprintPrefix.clear();
m_configured = false;
m_sequencerAvailable = false;
m_reachable = false;
}
bool SequencerNetworkContext::needsIdentityProbe() const
{
return m_configured
&& m_sequencerAvailable
&& m_reachable
&& !m_probeInFlight
&& (m_snapshot.status == QStringLiteral("loading")
|| m_snapshot.status == QStringLiteral("network_unknown"));
}
void SequencerNetworkContext::setSequencerAvailable(bool available)
{
if (m_sequencerAvailable == available)
return;
m_sequencerAvailable = available;
if (!m_configured)
return;
invalidateProbe();
clearIdentity(available && m_reachable ? QStringLiteral("loading")
: QStringLiteral("network_unknown"));
}
void SequencerNetworkContext::setReachable(bool reachable)
{
if (m_reachable == reachable)
return;
m_reachable = reachable;
if (!m_configured)
return;
invalidateProbe();
clearIdentity(reachable && m_sequencerAvailable ? QStringLiteral("loading")
: QStringLiteral("network_unknown"));
}
std::optional<quint64> SequencerNetworkContext::beginIdentityProbe()
{
if (!needsIdentityProbe())
return std::nullopt;
m_probeInFlight = true;
const quint64 generation = ++m_probeGeneration;
clearIdentity(QStringLiteral("loading"));
return generation;
}
bool SequencerNetworkContext::finishIdentityProbe(quint64 generation,
const QString& identity)
{
if (!m_configured
|| !m_sequencerAvailable
|| !m_reachable
|| !m_probeInFlight
|| generation != m_probeGeneration) {
return false;
}
m_probeInFlight = false;
if (!isValidIdentity(identity)) {
clearIdentity(QStringLiteral("network_unknown"));
} else if (identity != m_expectedIdentity) {
clearIdentity(QStringLiteral("network_mismatch"));
} else {
m_snapshot.status = QStringLiteral("ready");
m_snapshot.fingerprint = m_fingerprintPrefix + identity;
}
return true;
}
bool SequencerNetworkContext::isValidIdentity(const QString& value)
{
return isLowerHex(value, 64);
}
void SequencerNetworkContext::clearIdentity(const QString& status)
{
m_snapshot.status = status;
m_snapshot.fingerprint.clear();
}
void SequencerNetworkContext::invalidateProbe()
{
++m_probeGeneration;
m_probeInFlight = false;
}
@@ -0,0 +1,59 @@
#pragma once
#include <optional>
#include <QString>
#include <QtGlobal>
// State shared by consumers that need to verify they are talking to a known
// sequencer. Deployment-specific configuration belongs to the consumer; this
// type only compares a supplied identity and tracks the probe lifecycle.
struct SequencerNetworkSnapshot {
QString id;
QString status = QStringLiteral("config_missing");
QString fingerprint;
};
class SequencerNetworkContext final {
public:
struct Configuration {
QString id;
QString expectedIdentity;
QString fingerprintPrefix;
};
// Replaces the active network. Returns false and publishes config_missing
// when the expected identity is not a 64-character lowercase hex value.
bool configure(Configuration configuration);
void clearConfiguration();
bool isConfigured() const { return m_configured; }
bool isReady() const { return m_snapshot.status == QStringLiteral("ready"); }
bool needsIdentityProbe() const;
const SequencerNetworkSnapshot& snapshot() const { return m_snapshot; }
// These inputs are intentionally separate: an endpoint can be configured
// while it is unreachable. Either loss invalidates an outstanding probe.
void setSequencerAvailable(bool available);
void setReachable(bool reachable);
// A caller must retain this generation and pass it back when its async RPC
// completes. Empty means a probe cannot currently start.
std::optional<quint64> beginIdentityProbe();
bool finishIdentityProbe(quint64 generation, const QString& identity);
static bool isValidIdentity(const QString& value);
private:
void clearIdentity(const QString& status);
void invalidateProbe();
SequencerNetworkSnapshot m_snapshot;
QString m_expectedIdentity;
QString m_fingerprintPrefix;
quint64 m_probeGeneration = 0;
bool m_configured = false;
bool m_sequencerAvailable = false;
bool m_reachable = false;
bool m_probeInFlight = false;
};
@@ -0,0 +1,60 @@
#include "SequencerNetworkSettings.h"
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QResource>
namespace {
std::optional<SequencerNetworkSettings> settingsForIdentity(
const QString& id,
const QString& identity,
const QString& fingerprintPrefix,
SequencerIdentityMethod method)
{
if (!SequencerNetworkContext::isValidIdentity(identity))
return std::nullopt;
SequencerNetworkSettings settings;
settings.context = { id, identity, fingerprintPrefix };
settings.identityMethod = method;
return settings;
}
}
std::optional<SequencerNetworkSettings> SequencerNetworkSettingsLoader::load(
const QString& networkId,
const QString& devnetConfigPath,
const QString& resourcePath)
{
Q_INIT_RESOURCE(logos_wallet_access_network_data);
const QString id = networkId.trimmed().isEmpty()
? QStringLiteral("testnet") : networkId.trimmed();
if (id == QStringLiteral("devnet")) {
QFile file(devnetConfigPath);
if (devnetConfigPath.isEmpty() || !file.open(QIODevice::ReadOnly))
return std::nullopt;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject())
return std::nullopt;
return settingsForIdentity(
id,
document.object().value(QStringLiteral("channelId")).toString(),
QStringLiteral("channel:"),
SequencerIdentityMethod::ChannelId);
}
QFile file(resourcePath);
if (!file.open(QIODevice::ReadOnly))
return std::nullopt;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject())
return std::nullopt;
const QJsonObject entry = document.object().value(id).toObject();
return settingsForIdentity(
id,
entry.value(QStringLiteral("checkpointHash")).toString(),
QStringLiteral("block10:"),
SequencerIdentityMethod::CheckpointBlock);
}
@@ -0,0 +1,27 @@
#pragma once
#include <optional>
#include <QString>
#include "SequencerNetworkContext.h"
enum class SequencerIdentityMethod {
CheckpointBlock,
ChannelId,
};
struct SequencerNetworkSettings {
SequencerNetworkContext::Configuration context;
SequencerIdentityMethod identityMethod = SequencerIdentityMethod::CheckpointBlock;
};
// Loads the identity contract for a wallet network. Program deployments and
// application-specific assets deliberately stay outside this loader.
class SequencerNetworkSettingsLoader final {
public:
static std::optional<SequencerNetworkSettings> load(
const QString& networkId,
const QString& devnetConfigPath,
const QString& resourcePath = QStringLiteral(":/wallet/config/networks.json"));
};
+306 -9
View File
@@ -1,5 +1,11 @@
#include "WalletAccountModel.h"
#include <utility>
namespace {
const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0'));
}
WalletAccountModel::WalletAccountModel(QObject* parent)
: QAbstractListModel(parent)
{
@@ -21,10 +27,38 @@ QVariant WalletAccountModel::data(const QModelIndex& index, int role) const
return account.name;
case AddressRole:
return account.address;
case DisplayAddressRole:
return account.displayAddress;
case BalanceRole:
return account.balance;
case IsPublicRole:
return account.isPublic;
case KindRole:
return account.kind;
case SectionRole:
return account.section;
case ProgramOwnerRole:
return account.programOwner;
case ReadStatusRole:
return account.readStatus;
case ProgramNameRole:
return account.programName;
case AccountTypeRole:
return account.accountType;
case VisibilityRole:
return account.isPublic ? QStringLiteral("public") : QStringLiteral("private");
case ControlRole:
return QStringLiteral("wallet");
case CanBePrimaryRole:
return account.canBePrimary;
case IsPrimaryRole:
return account.isPrimary;
case DefinitionIdRole:
return account.definitionId;
case AliasRole:
return account.alias;
case DecodedDataRole:
return account.decodedData;
default:
return {};
}
@@ -37,25 +71,288 @@ QHash<int, QByteArray> WalletAccountModel::roleNames() const
{ AddressRole, "address" },
{ BalanceRole, "balance" },
{ IsPublicRole, "isPublic" },
{ KindRole, "kind" },
{ SectionRole, "section" },
{ ProgramOwnerRole, "programOwner" },
{ ReadStatusRole, "readStatus" },
{ ProgramNameRole, "programName" },
{ AccountTypeRole, "accountType" },
{ VisibilityRole, "visibility" },
{ ControlRole, "control" },
{ CanBePrimaryRole, "canBePrimary" },
{ IsPrimaryRole, "isPrimary" },
{ DefinitionIdRole, "definitionId" },
{ AliasRole, "alias" },
{ DisplayAddressRole, "displayAddress" },
{ DecodedDataRole, "decodedData" },
};
}
void WalletAccountModel::replaceAccounts(const QVector<WalletAccount>& accounts)
void WalletAccountModel::replaceAccounts(const QVector<WalletAccount>& accounts,
const QHash<QString, QString>& aliases,
const QString& primaryAddress)
{
beginResetModel();
const qsizetype oldCount = m_accounts.size();
m_accounts.clear();
m_accounts.reserve(accounts.size());
for (qsizetype index = 0; index < accounts.size(); ++index) {
const WalletAccount& account = accounts.at(index);
m_accounts.append({
QStringLiteral("Account %1").arg(index + 1),
account.address,
account.balance,
account.isPublic,
});
for (const WalletAccount& account : accounts) {
Entry entry;
entry.alias = aliases.value(account.address);
entry.address = account.address;
entry.displayAddress = account.displayAddress.isEmpty()
? account.address : account.displayAddress;
entry.balance = account.balance;
entry.isPublic = account.isPublic;
entry.programOwner = account.programOwner;
entry.readStatus = account.readStatus;
if (!account.isPublic) {
entry.kind = QStringLiteral("private");
entry.canBePrimary = true;
} else if (account.readStatus != QStringLiteral("ok")) {
entry.kind = QStringLiteral("unknown");
} else if (account.programOwner == DEFAULT_PROGRAM_OWNER) {
entry.kind = QStringLiteral("user");
entry.canBePrimary = true;
} else {
entry.kind = QStringLiteral("program");
}
entry.section = sectionFor(entry);
entry.isPrimary = account.address == primaryAddress && entry.canBePrimary;
updateEntryName(entry);
m_accounts.append(std::move(entry));
}
endResetModel();
if (oldCount != m_accounts.size())
emit countChanged();
}
bool WalletAccountModel::applyPresentations(
const QVector<WalletAccountPresentation>& presentations)
{
if (presentations.isEmpty())
return clearPresentations();
QHash<QString, int> rowsByAddress;
rowsByAddress.reserve(m_accounts.size());
for (int row = 0; row < m_accounts.size(); ++row) {
const QString& address = m_accounts.at(row).address;
if (!rowsByAddress.contains(address))
rowsByAddress.insert(address, row);
}
int firstChanged = m_accounts.size();
int lastChanged = -1;
for (const WalletAccountPresentation& presentation : presentations) {
const auto row = rowsByAddress.constFind(presentation.address);
if (row == rowsByAddress.cend())
continue;
const Entry current = m_accounts.at(row.value());
Entry entry = current;
if (!presentation.kind.isEmpty())
entry.kind = presentation.kind;
entry.programName = presentation.programName;
entry.accountType = presentation.accountType;
entry.definitionId = presentation.definitionId;
entry.decodedData = presentation.decodedData;
entry.semanticName = presentation.semanticName;
entry.section = sectionFor(entry, presentation.hiddenFromAccounts);
entry.canBePrimary = entry.kind == QStringLiteral("user")
|| entry.kind == QStringLiteral("private");
if (!entry.canBePrimary)
entry.isPrimary = false;
updateEntryName(entry);
if (entry.alias == current.alias
&& entry.semanticName == current.semanticName
&& entry.name == current.name
&& entry.address == current.address
&& entry.displayAddress == current.displayAddress
&& entry.balance == current.balance
&& entry.isPublic == current.isPublic
&& entry.kind == current.kind
&& entry.section == current.section
&& entry.programOwner == current.programOwner
&& entry.readStatus == current.readStatus
&& entry.programName == current.programName
&& entry.accountType == current.accountType
&& entry.definitionId == current.definitionId
&& entry.decodedData == current.decodedData
&& entry.canBePrimary == current.canBePrimary
&& entry.isPrimary == current.isPrimary) {
continue;
}
m_accounts[row.value()] = std::move(entry);
if (row.value() < firstChanged)
firstChanged = row.value();
if (row.value() > lastChanged)
lastChanged = row.value();
}
if (lastChanged < 0)
return false;
emit dataChanged(index(firstChanged), index(lastChanged), {
NameRole,
KindRole,
SectionRole,
ProgramNameRole,
AccountTypeRole,
DecodedDataRole,
CanBePrimaryRole,
IsPrimaryRole,
DefinitionIdRole,
});
return true;
}
bool WalletAccountModel::clearPresentations()
{
int firstChanged = m_accounts.size();
int lastChanged = -1;
for (int row = 0; row < m_accounts.size(); ++row) {
Entry& entry = m_accounts[row];
const Entry current = entry;
resetPresentation(entry);
if (entry.alias == current.alias
&& entry.semanticName == current.semanticName
&& entry.name == current.name
&& entry.kind == current.kind
&& entry.section == current.section
&& entry.programName == current.programName
&& entry.accountType == current.accountType
&& entry.definitionId == current.definitionId
&& entry.decodedData == current.decodedData
&& entry.canBePrimary == current.canBePrimary
&& entry.isPrimary == current.isPrimary) {
continue;
}
if (row < firstChanged)
firstChanged = row;
lastChanged = row;
}
if (lastChanged < 0)
return false;
emit dataChanged(index(firstChanged), index(lastChanged), {
NameRole,
KindRole,
SectionRole,
ProgramNameRole,
AccountTypeRole,
DecodedDataRole,
CanBePrimaryRole,
IsPrimaryRole,
DefinitionIdRole,
});
return true;
}
void WalletAccountModel::setAlias(const QString& address, const QString& alias)
{
const int row = indexOf(address);
if (row < 0)
return;
Entry& entry = m_accounts[row];
entry.alias = alias;
updateEntryName(entry);
emit dataChanged(index(row), index(row), { NameRole, AliasRole });
}
void WalletAccountModel::setPrimaryAddress(const QString& address)
{
for (int row = 0; row < m_accounts.size(); ++row) {
Entry& entry = m_accounts[row];
const bool next = entry.address == address && entry.canBePrimary;
if (entry.isPrimary == next)
continue;
entry.isPrimary = next;
emit dataChanged(index(row), index(row), { IsPrimaryRole });
}
}
bool WalletAccountModel::contains(const QString& address) const
{
return indexOf(address) >= 0;
}
bool WalletAccountModel::canBePrimary(const QString& address) const
{
const int row = indexOf(address);
return row >= 0 && m_accounts.at(row).canBePrimary;
}
QString WalletAccountModel::firstAutomaticPrimary() const
{
for (const Entry& entry : m_accounts) {
if (entry.kind == QStringLiteral("user"))
return entry.address;
}
return {};
}
int WalletAccountModel::indexOf(const QString& address) const
{
for (int row = 0; row < m_accounts.size(); ++row) {
if (m_accounts.at(row).address == address)
return row;
}
return -1;
}
QString WalletAccountModel::defaultName(const Entry& entry)
{
if (!entry.accountType.isEmpty()) {
QString name = entry.accountType;
for (qsizetype index = 1; index < name.size(); ++index) {
if (name.at(index).isUpper() && name.at(index - 1).isLower())
name.insert(index++, QLatin1Char(' '));
}
return name;
}
if (entry.kind == QStringLiteral("user"))
return QStringLiteral("User account");
if (entry.kind == QStringLiteral("private"))
return QStringLiteral("Private account");
if (entry.kind == QStringLiteral("unknown"))
return QStringLiteral("Unknown account");
return QStringLiteral("Program account");
}
QString WalletAccountModel::sectionFor(const Entry& entry, bool hiddenFromAccounts)
{
if (hiddenFromAccounts || entry.kind == QStringLiteral("token_holding"))
return QStringLiteral("hidden");
if (entry.kind == QStringLiteral("user") || entry.kind == QStringLiteral("private"))
return QStringLiteral("accounts");
return QStringLiteral("advanced");
}
void WalletAccountModel::resetPresentation(Entry& entry)
{
entry.semanticName.clear();
entry.programName.clear();
entry.accountType.clear();
entry.definitionId.clear();
entry.decodedData.clear();
if (!entry.isPublic) {
entry.kind = QStringLiteral("private");
entry.canBePrimary = true;
} else if (entry.readStatus != QStringLiteral("ok")) {
entry.kind = QStringLiteral("unknown");
entry.canBePrimary = false;
} else if (entry.programOwner == DEFAULT_PROGRAM_OWNER) {
entry.kind = QStringLiteral("user");
entry.canBePrimary = true;
} else {
entry.kind = QStringLiteral("program");
entry.canBePrimary = false;
}
if (!entry.canBePrimary)
entry.isPrimary = false;
entry.section = sectionFor(entry);
updateEntryName(entry);
}
void WalletAccountModel::updateEntryName(Entry& entry)
{
entry.name = !entry.alias.isEmpty()
? entry.alias
: (!entry.semanticName.isEmpty() ? entry.semanticName : defaultName(entry));
}
+55 -1
View File
@@ -1,10 +1,22 @@
#pragma once
#include <QAbstractListModel>
#include <QHash>
#include <QVector>
#include "WalletProvider.h"
struct WalletAccountPresentation {
QString address;
QString kind;
QString semanticName;
QString programName;
QString accountType;
QString definitionId;
bool hiddenFromAccounts = false;
QString decodedData;
};
class WalletAccountModel final : public QAbstractListModel {
Q_OBJECT
Q_PROPERTY(int count READ count NOTIFY countChanged)
@@ -15,6 +27,20 @@ public:
AddressRole,
BalanceRole,
IsPublicRole,
KindRole,
SectionRole,
ProgramOwnerRole,
ReadStatusRole,
ProgramNameRole,
AccountTypeRole,
VisibilityRole,
ControlRole,
CanBePrimaryRole,
IsPrimaryRole,
DefinitionIdRole,
AliasRole,
DisplayAddressRole,
DecodedDataRole,
};
Q_ENUM(Role)
@@ -24,7 +50,17 @@ public:
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
void replaceAccounts(const QVector<WalletAccount>& accounts);
void replaceAccounts(const QVector<WalletAccount>& accounts,
const QHash<QString, QString>& aliases = {},
const QString& primaryAddress = {});
bool applyPresentations(const QVector<WalletAccountPresentation>& presentations);
bool clearPresentations();
void setAlias(const QString& address, const QString& alias);
void setPrimaryAddress(const QString& address);
bool contains(const QString& address) const;
bool canBePrimary(const QString& address) const;
QString firstAutomaticPrimary() const;
int indexOf(const QString& address) const;
int count() const { return m_accounts.size(); }
signals:
@@ -32,11 +68,29 @@ signals:
private:
struct Entry {
QString alias;
QString semanticName;
QString name;
QString address;
QString displayAddress;
QString balance;
bool isPublic = true;
QString kind;
QString section;
QString programOwner;
QString readStatus;
QString programName;
QString accountType;
QString definitionId;
QString decodedData;
bool canBePrimary = false;
bool isPrimary = false;
};
static QString defaultName(const Entry& entry);
static QString sectionFor(const Entry& entry, bool hiddenFromAccounts = false);
void resetPresentation(Entry& entry);
void updateEntryName(Entry& entry);
QVector<Entry> m_accounts;
};
+354 -39
View File
@@ -3,11 +3,17 @@
#include <utility>
#include <QDebug>
#include <QCryptographicHash>
#include <QDir>
#include <QFileInfo>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QSaveFile>
#include <QSettings>
#include <QTimer>
#include <QUrl>
@@ -18,6 +24,10 @@ namespace {
const char SETTINGS_ORG[] = "Logos";
const char DISCONNECTED_KEY[] = "disconnected";
const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR";
const char WALLET_SETTINGS_GROUP[] = "wallets";
const char ALIASES_KEY[] = "aliases";
const char PRIMARY_ACCOUNT_KEY[] = "primaryAccount";
constexpr qsizetype MAX_ALIAS_LENGTH = 40;
QString toLocalPath(const QString& path)
{
@@ -25,6 +35,26 @@ QString toLocalPath(const QString& path)
return QUrl::fromUserInput(path).toLocalFile();
return path;
}
QString configuredSequencer(const QString& path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return {};
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject())
return {};
return document.object().value(QStringLiteral("sequencer_addr")).toString();
}
QString canonicalStoragePath(const QString& path)
{
const QFileInfo info(path);
const QString canonical = info.canonicalFilePath();
return canonical.isEmpty()
? QDir::cleanPath(info.absoluteFilePath())
: canonical;
}
}
WalletController::WalletController(WalletProvider& wallet,
@@ -38,7 +68,10 @@ WalletController::WalletController(WalletProvider& wallet,
m_reachabilityTimer(new QTimer(this))
{
m_state.walletHome = defaultWalletHome();
m_state.configPath = defaultConfigPath();
m_state.storagePath = defaultStoragePath();
m_state.walletExists = QFileInfo::exists(defaultStoragePath());
m_state.sequencerAddress = configuredSequencer(defaultConfigPath());
m_reachabilityTimer->setInterval(10000);
connect(m_reachabilityTimer, &QTimer::timeout,
@@ -65,12 +98,56 @@ QString WalletController::defaultStoragePath() const
return m_state.walletHome + QStringLiteral("/storage.json");
}
void WalletController::setDefaultSequencerAddress(const QString& address)
{
const QString normalized = address.trimmed();
const QUrl endpoint(normalized);
const QString scheme = endpoint.scheme().toLower();
if (endpoint.isValid()
&& !endpoint.host().isEmpty()
&& (scheme == QStringLiteral("http") || scheme == QStringLiteral("https"))) {
m_defaultSequencerAddress = normalized;
} else {
m_defaultSequencerAddress.clear();
}
}
bool WalletController::seedDefaultWalletConfig(const QString& configPath) const
{
if (m_defaultSequencerAddress.isEmpty() || QFileInfo::exists(configPath))
return true;
const QFileInfo configInfo(configPath);
if (!QDir().mkpath(configInfo.absolutePath())) {
qWarning() << "WalletController: failed to create wallet configuration directory";
return false;
}
QSaveFile config(configPath);
if (!config.open(QIODevice::WriteOnly)) {
qWarning() << "WalletController: failed to open wallet configuration";
return false;
}
const QByteArray contents = QJsonDocument(QJsonObject {
{ QStringLiteral("sequencer_addr"), m_defaultSequencerAddress },
{ QStringLiteral("seq_poll_timeout"), QStringLiteral("12s") },
{ QStringLiteral("seq_tx_poll_max_blocks"), 5 },
{ QStringLiteral("seq_poll_max_retries"), 5 },
{ QStringLiteral("seq_block_poll_max_amount"), 100 },
}).toJson(QJsonDocument::Compact);
if (config.write(contents) != contents.size() || !config.commit()) {
qWarning() << "WalletController: failed to save wallet configuration";
return false;
}
return true;
}
void WalletController::start()
{
if (m_started)
return;
m_started = true;
m_reachabilityTimer->start();
QTimer::singleShot(0, this, &WalletController::openOnStartup);
}
@@ -83,31 +160,76 @@ void WalletController::openOnStartup()
const QString config = defaultConfigPath();
const QString storage = defaultStoragePath();
const WalletSession session = m_wallet.connect({ config, storage });
if (session.failure == WalletFailure::WalletMissing)
return;
if (!session.ok()) {
qWarning() << "WalletController: wallet connection failed"
<< walletFailureCode(session.failure);
return;
beginOpen(config, storage);
}
bool WalletController::beginOpen(const QString& config, const QString& storage)
{
if (m_state.syncStatus == QStringLiteral("opening")
|| m_state.syncStatus == QStringLiteral("syncing")) {
return false;
}
const quint64 generation = ++m_operationGeneration;
m_state.configPath = config;
m_state.storagePath = storage;
m_state.walletExists = QFileInfo::exists(storage) || session.adopted;
m_state.isWalletOpen = true;
applySnapshot(session.snapshot);
m_state.syncStatus = QStringLiteral("opening");
m_state.syncError.clear();
const QString endpoint = configuredSequencer(config);
if (!endpoint.isEmpty())
m_state.sequencerAddress = endpoint;
emit stateChanged();
QTimer::singleShot(0, this, [this, generation]() {
if (generation == m_operationGeneration
&& m_state.syncStatus == QStringLiteral("opening")) {
m_state.syncStatus = QStringLiteral("syncing");
emit stateChanged();
}
});
const QPointer<WalletController> guard(this);
m_wallet.connectAsync({ config, storage },
[guard, generation, config, storage](WalletSession session) {
if (!guard || generation != guard->m_operationGeneration)
return;
if (session.failure == WalletFailure::WalletMissing) {
guard->m_state.syncStatus = QStringLiteral("closed");
guard->m_state.walletExists = false;
emit guard->stateChanged();
return;
}
if (!session.ok()) {
qWarning() << "WalletController: wallet connection failed"
<< walletFailureCode(session.failure);
guard->m_state.syncStatus = QStringLiteral("error");
guard->m_state.syncError = walletFailureCode(session.failure);
emit guard->stateChanged();
return;
}
guard->m_state.configPath = config;
guard->m_state.storagePath = storage;
guard->m_state.walletExists = QFileInfo::exists(storage) || session.adopted;
guard->m_state.isWalletOpen = true;
guard->m_state.syncStatus = QStringLiteral("ready");
guard->applySnapshot(session.snapshot);
});
return true;
}
QString WalletController::createDefaultWallet(const QString& password)
{
return createWallet(defaultConfigPath(), defaultStoragePath(), password);
const QString config = defaultConfigPath();
if (!seedDefaultWalletConfig(config))
return {};
return createWallet(config, defaultStoragePath(), password);
}
QString WalletController::createWallet(const QString& configPath,
const QString& storagePath,
const QString& password)
{
const quint64 generation = ++m_operationGeneration;
const QString config = toLocalPath(configPath);
const QString storage = toLocalPath(storagePath);
const WalletCreation creation = m_wallet.createWallet(
@@ -117,20 +239,50 @@ QString WalletController::createWallet(const QString& configPath,
<< walletFailureCode(creation.failure);
return {};
}
stopReachability();
m_state.configPath = config;
m_state.storagePath = storage;
m_state.walletExists = true;
QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false);
if (!creation.ok()) {
qWarning() << "WalletController: wallet creation failed"
<< walletFailureCode(creation.failure);
m_state.walletExists = QFileInfo::exists(storage);
m_state.isWalletOpen = false;
m_state.syncStatus = QStringLiteral("error");
m_state.syncError = walletFailureCode(creation.failure);
emit stateChanged();
return creation.mnemonic;
}
m_state.walletExists = true;
m_state.isWalletOpen = true;
applySnapshot(creation.snapshot);
m_state.syncStatus = QStringLiteral("syncing");
m_state.syncError.clear();
m_accountModel->replaceAccounts({});
emit stateChanged();
const QPointer<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;
}
@@ -140,29 +292,72 @@ bool WalletController::open()
? defaultConfigPath() : m_state.configPath;
const QString storage = m_state.storagePath.isEmpty()
? defaultStoragePath() : m_state.storagePath;
const WalletSession session = m_wallet.connect({ config, storage });
if (!session.ok()) {
qWarning() << "WalletController: wallet open failed"
<< walletFailureCode(session.failure);
return false;
}
m_state.configPath = config;
m_state.storagePath = storage;
m_state.walletExists = true;
m_state.isWalletOpen = true;
QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false);
applySnapshot(session.snapshot);
return true;
return beginOpen(config, storage);
}
void WalletController::disconnect()
{
++m_operationGeneration;
stopReachability();
m_wallet.disconnect();
m_state.isWalletOpen = false;
m_state.syncStatus = QStringLiteral("closed");
m_state.syncError.clear();
m_accountModel->replaceAccounts({});
QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true);
emit stateChanged();
emit snapshotChanged();
}
bool WalletController::setAccountAlias(const QString& address, const QString& alias)
{
if (!m_accountModel->contains(address))
return false;
const QString normalized = alias.trimmed();
if (normalized.size() > MAX_ALIAS_LENGTH)
return false;
if (normalized.isEmpty())
m_aliases.remove(address);
else
m_aliases.insert(address, normalized);
m_accountModel->setAlias(address, normalized);
storeAliases(m_aliases);
updatePrimaryState(m_state.primaryAccountAddress);
emit stateChanged();
return true;
}
bool WalletController::setPrimaryAccount(const QString& address)
{
if (!m_accountModel->canBePrimary(address))
return false;
m_accountModel->setPrimaryAddress(address);
storePrimaryAccount(address);
updatePrimaryState(address);
emit stateChanged();
return true;
}
void WalletController::applyAccountPresentations(
const QVector<WalletAccountPresentation>& presentations)
{
if (!m_accountModel->applyPresentations(presentations))
return;
const QString previousPrimary = m_state.primaryAccountAddress;
const QString previousPrimaryName = m_state.primaryAccountName;
QString primary = m_state.primaryAccountAddress;
if (!m_accountModel->canBePrimary(primary))
primary = m_accountModel->firstAutomaticPrimary();
m_accountModel->setPrimaryAddress(primary);
if (primary != previousPrimary)
storePrimaryAccount(primary);
updatePrimaryState(primary);
if (m_state.primaryAccountAddress != previousPrimary
|| m_state.primaryAccountName != previousPrimaryName) {
emit stateChanged();
}
}
QString WalletController::createAccount(bool isPublic)
@@ -174,23 +369,42 @@ QString WalletController::createAccount(bool isPublic)
return {};
}
if (creation.snapshot.ok()) {
m_state.syncStatus = QStringLiteral("ready");
m_state.syncError.clear();
applySnapshot(creation.snapshot);
} else {
qWarning() << "WalletController: account refresh failed"
<< walletFailureCode(creation.snapshot.failure);
m_state.syncStatus = QStringLiteral("error");
m_state.syncError = walletFailureCode(creation.snapshot.failure);
emit stateChanged();
}
return creation.accountId;
}
void WalletController::refresh()
{
const WalletSnapshot next = m_wallet.snapshot(true);
if (next.ok()) {
applySnapshot(next);
} else {
qWarning() << "WalletController: wallet refresh failed"
<< walletFailureCode(next.failure);
}
if (!m_state.isWalletOpen || m_state.syncStatus == QStringLiteral("syncing"))
return;
const quint64 generation = ++m_operationGeneration;
m_state.syncStatus = QStringLiteral("syncing");
m_state.syncError.clear();
emit stateChanged();
const QPointer<WalletController> guard(this);
m_wallet.snapshotAsync(true, [guard, generation](WalletSnapshot next) {
if (!guard || generation != guard->m_operationGeneration)
return;
if (next.ok()) {
guard->m_state.syncStatus = QStringLiteral("ready");
guard->applySnapshot(next);
} else {
qWarning() << "WalletController: wallet refresh failed"
<< walletFailureCode(next.failure);
guard->m_state.syncStatus = QStringLiteral("error");
guard->m_state.syncError = walletFailureCode(next.failure);
emit guard->stateChanged();
}
});
}
QString WalletController::balance(const QString& accountId, bool isPublic)
@@ -205,24 +419,125 @@ QString WalletController::balance(const QString& accountId, bool isPublic)
void WalletController::applySnapshot(const WalletSnapshot& snapshot)
{
m_accountModel->replaceAccounts(snapshot.accounts);
m_snapshot = snapshot;
m_aliases = loadAliases();
QString primary = loadPrimaryAccount();
m_accountModel->replaceAccounts(snapshot.accounts, m_aliases, primary);
if (!m_accountModel->canBePrimary(primary))
primary = m_accountModel->firstAutomaticPrimary();
m_accountModel->setPrimaryAddress(primary);
storePrimaryAccount(primary);
updatePrimaryState(primary);
m_state.lastSyncedBlock = static_cast<int>(snapshot.lastSyncedBlock);
m_state.currentBlockHeight = static_cast<int>(snapshot.currentBlockHeight);
m_state.sequencerAddress = snapshot.sequencerAddress;
if (!snapshot.sequencerAddress.isEmpty())
m_state.sequencerAddress = snapshot.sequencerAddress;
emit snapshotChanged();
emit stateChanged();
if (!m_reachabilityTimer->isActive())
m_reachabilityTimer->start();
checkReachability();
}
QString WalletController::walletSettingsGroup() const
{
const QByteArray hash = QCryptographicHash::hash(
canonicalStoragePath(m_state.storagePath).toUtf8(),
QCryptographicHash::Sha256).toHex();
return QStringLiteral("%1/%2")
.arg(QString::fromLatin1(WALLET_SETTINGS_GROUP), QString::fromLatin1(hash));
}
QHash<QString, QString> WalletController::loadAliases() const
{
QSettings settings(SETTINGS_ORG, m_settingsApplication);
settings.beginGroup(walletSettingsGroup());
const QVariantMap stored = settings.value(ALIASES_KEY).toMap();
QHash<QString, QString> aliases;
for (auto iterator = stored.cbegin(); iterator != stored.cend(); ++iterator) {
const QString alias = iterator.value().toString().trimmed();
if (!alias.isEmpty() && alias.size() <= MAX_ALIAS_LENGTH)
aliases.insert(iterator.key(), alias);
}
return aliases;
}
QString WalletController::loadPrimaryAccount() const
{
QSettings settings(SETTINGS_ORG, m_settingsApplication);
settings.beginGroup(walletSettingsGroup());
return settings.value(PRIMARY_ACCOUNT_KEY).toString();
}
void WalletController::storeAliases(const QHash<QString, QString>& aliases) const
{
QVariantMap stored;
for (auto iterator = aliases.cbegin(); iterator != aliases.cend(); ++iterator)
stored.insert(iterator.key(), iterator.value());
QSettings settings(SETTINGS_ORG, m_settingsApplication);
settings.beginGroup(walletSettingsGroup());
settings.setValue(ALIASES_KEY, stored);
}
void WalletController::storePrimaryAccount(const QString& address) const
{
QSettings settings(SETTINGS_ORG, m_settingsApplication);
settings.beginGroup(walletSettingsGroup());
if (address.isEmpty())
settings.remove(PRIMARY_ACCOUNT_KEY);
else
settings.setValue(PRIMARY_ACCOUNT_KEY, address);
}
void WalletController::updatePrimaryState(const QString& address)
{
m_state.primaryAccountAddress = address;
m_state.primaryAccountName.clear();
const int row = m_accountModel->indexOf(address);
if (row >= 0) {
m_state.primaryAccountName = m_accountModel->data(
m_accountModel->index(row), WalletAccountModel::NameRole).toString();
}
}
void WalletController::stopReachability()
{
m_reachabilityTimer->stop();
++m_reachabilityGeneration;
if (m_reachabilityReply) {
QNetworkReply* reply = m_reachabilityReply;
m_reachabilityReply = nullptr;
m_reachabilityEndpoint.clear();
reply->abort();
}
}
void WalletController::checkReachability()
{
if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty())
return;
QNetworkRequest request{QUrl(m_state.sequencerAddress)};
const QString endpoint = m_state.sequencerAddress;
if (m_reachabilityReply && endpoint == m_reachabilityEndpoint)
return;
const quint64 generation = ++m_reachabilityGeneration;
if (m_reachabilityReply)
m_reachabilityReply->abort();
QNetworkRequest request{QUrl(endpoint)};
request.setTransferTimeout(4000);
QNetworkReply* reply = m_network->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
if (!m_state.isWalletOpen) {
m_reachabilityReply = reply;
m_reachabilityEndpoint = endpoint;
connect(reply, &QNetworkReply::finished, this,
[this, reply, generation, endpoint]() {
if (m_reachabilityReply == reply) {
m_reachabilityReply = nullptr;
m_reachabilityEndpoint.clear();
}
if (!m_state.isWalletOpen
|| generation != m_reachabilityGeneration
|| endpoint != m_state.sequencerAddress) {
reply->deleteLater();
return;
}
+36
View File
@@ -1,13 +1,17 @@
#pragma once
#include <QObject>
#include <QHash>
#include <QString>
#include <QVector>
#include "WalletProvider.h"
class QNetworkAccessManager;
class QNetworkReply;
class QTimer;
class WalletAccountModel;
struct WalletAccountPresentation;
struct WalletUiState {
bool isWalletOpen = false;
@@ -19,6 +23,15 @@ struct WalletUiState {
int currentBlockHeight = 0;
QString sequencerAddress;
bool sequencerReachable = true;
QString syncStatus = QStringLiteral("closed");
QString syncError;
QString primaryAccountAddress;
QString primaryAccountName;
bool canSubmit() const
{
return isWalletOpen && syncStatus == QStringLiteral("ready");
}
};
class WalletController final : public QObject {
@@ -33,8 +46,10 @@ public:
WalletAccountModel* accountModel() const { return m_accountModel; }
const WalletUiState& state() const { return m_state; }
const WalletSnapshot& snapshot() const { return m_snapshot; }
void start();
void setDefaultSequencerAddress(const QString& address);
QString createAccount(bool isPublic);
void refresh();
QString balance(const QString& accountId, bool isPublic);
@@ -44,24 +59,45 @@ public:
const QString& password);
bool open();
void disconnect();
bool setAccountAlias(const QString& address, const QString& alias);
bool setPrimaryAccount(const QString& address);
void applyAccountPresentations(
const QVector<WalletAccountPresentation>& presentations);
signals:
void stateChanged();
void snapshotChanged();
private:
static QString defaultWalletHome();
QString defaultConfigPath() const;
QString defaultStoragePath() const;
bool seedDefaultWalletConfig(const QString& configPath) const;
void openOnStartup();
bool beginOpen(const QString& config, const QString& storage);
void applySnapshot(const WalletSnapshot& snapshot);
void checkReachability();
void stopReachability();
QString walletSettingsGroup() const;
QHash<QString, QString> loadAliases() const;
QString loadPrimaryAccount() const;
void storeAliases(const QHash<QString, QString>& aliases) const;
void storePrimaryAccount(const QString& address) const;
void updatePrimaryState(const QString& address);
WalletProvider& m_wallet;
QString m_settingsApplication;
WalletUiState m_state;
WalletSnapshot m_snapshot;
QHash<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;
};
@@ -0,0 +1,70 @@
#include "WalletIdlDecoder.h"
#include <utility>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <wallet_idl_decoder.h>
WalletDecodeResult WalletIdlDecoder::decode(
const QByteArray& idlJson,
const QVector<WalletAccountRead>& accounts)
{
WalletDecodeResult result;
QJsonParseError idlError;
const QJsonDocument idl = QJsonDocument::fromJson(idlJson, &idlError);
if (idlError.error != QJsonParseError::NoError || !idl.isObject()) {
result.status = QStringLiteral("error");
result.error = QStringLiteral("invalid_idl");
return result;
}
QJsonArray inputs;
for (const WalletAccountRead& account : accounts) {
inputs.append(QJsonObject {
{ QStringLiteral("id"), account.accountId },
{ QStringLiteral("dataHex"), account.dataHex },
});
}
const QByteArray request = QJsonDocument(QJsonObject {
{ QStringLiteral("idl"), idl.object() },
{ QStringLiteral("accounts"), inputs },
}).toJson(QJsonDocument::Compact);
char* responsePointer = wallet_idl_decode_accounts(request.constData());
if (!responsePointer) {
result.status = QStringLiteral("error");
result.error = QStringLiteral("decoder_unavailable");
return result;
}
const QByteArray response(responsePointer);
wallet_idl_decoder_free(responsePointer);
QJsonParseError responseError;
const QJsonDocument document = QJsonDocument::fromJson(response, &responseError);
if (responseError.error != QJsonParseError::NoError || !document.isObject()) {
result.status = QStringLiteral("error");
result.error = QStringLiteral("invalid_decoder_response");
return result;
}
const QJsonObject root = document.object();
result.status = root.value(QStringLiteral("status")).toString();
result.error = root.value(QStringLiteral("error")).toString();
for (const QJsonValue& value : root.value(QStringLiteral("accounts")).toArray()) {
const QJsonObject decoded = value.toObject();
WalletDecodedAccount account;
account.id = decoded.value(QStringLiteral("id")).toString();
account.status = decoded.value(QStringLiteral("status")).toString();
account.typeName = decoded.value(QStringLiteral("typeName")).toString();
account.value = decoded.value(QStringLiteral("value"));
const QJsonObject ids = decoded.value(QStringLiteral("accountIds")).toObject();
for (auto iterator = ids.begin(); iterator != ids.end(); ++iterator)
account.accountIds.insert(iterator.key(), iterator.value().toString());
result.accounts.append(std::move(account));
}
return result;
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <QByteArray>
#include <QHash>
#include <QJsonValue>
#include <QString>
#include <QVector>
#include "WalletProvider.h"
struct WalletDecodedAccount {
QString id;
QString status;
QString typeName;
QJsonValue value;
QHash<QString, QString> accountIds;
};
struct WalletDecodeResult {
QString status;
QString error;
QVector<WalletDecodedAccount> accounts;
bool ok() const { return status == QStringLiteral("ok"); }
};
class WalletIdlDecoder final {
public:
static WalletDecodeResult decode(const QByteArray& idlJson,
const QVector<WalletAccountRead>& accounts);
};
@@ -0,0 +1,377 @@
#include "WalletPortfolioService.h"
#include <algorithm>
#include <utility>
#include <QCryptographicHash>
#include <QHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <QVariantMap>
namespace {
QJsonObject enumFields(const QJsonValue& value, const QString& variant)
{
return value.toObject().value(variant).toObject();
}
QString decodedDataText(const QJsonValue& value)
{
if (value.isObject()) {
return QString::fromUtf8(
QJsonDocument(value.toObject()).toJson(QJsonDocument::Indented)).trimmed();
}
if (value.isArray()) {
return QString::fromUtf8(
QJsonDocument(value.toArray()).toJson(QJsonDocument::Indented)).trimmed();
}
return {};
}
QString decimalAdd(const QString& left, const QString& right)
{
if (left.isEmpty() || right.isEmpty())
return {};
if (!std::all_of(left.cbegin(), left.cend(), [](QChar value) { return value.isDigit(); })
|| !std::all_of(right.cbegin(), right.cend(), [](QChar value) { return value.isDigit(); })) {
return {};
}
QString result;
result.reserve(std::max(left.size(), right.size()) + 1);
qsizetype leftIndex = left.size();
qsizetype rightIndex = right.size();
int carry = 0;
while (leftIndex > 0 || rightIndex > 0 || carry > 0) {
const int leftDigit = leftIndex > 0 ? left.at(--leftIndex).digitValue() : 0;
const int rightDigit = rightIndex > 0 ? right.at(--rightIndex).digitValue() : 0;
const int sum = leftDigit + rightDigit + carry;
result.prepend(QChar(QLatin1Char('0').unicode() + sum % 10));
carry = sum / 10;
}
while (result.size() > 1 && result.startsWith(QLatin1Char('0')))
result.remove(0, 1);
return result;
}
void addField(QCryptographicHash& hash, const QString& value)
{
const QByteArray utf8 = value.toUtf8();
hash.addData(QByteArray::number(utf8.size()));
hash.addData(QByteArrayLiteral(":"));
hash.addData(utf8);
hash.addData(QByteArrayLiteral(";"));
}
QByteArray accountReadsSignature(const QVector<WalletAccountRead>& reads)
{
QCryptographicHash hash(QCryptographicHash::Sha256);
hash.addData(QByteArray::number(reads.size()));
hash.addData(QByteArrayLiteral(";"));
for (const WalletAccountRead& read : reads) {
addField(hash, read.accountId);
addField(hash, read.status);
addField(hash, read.programOwner);
addField(hash, read.balanceHex);
addField(hash, read.nonceHex);
addField(hash, read.dataHex);
}
return hash.result();
}
WalletPortfolioResult failureResult(const QString& status, const QString& error)
{
WalletPortfolioResult result;
result.status = status;
result.error = error;
return result;
}
}
struct WalletPortfolioService::State {
struct Program {
QString name;
QByteArray idl;
};
struct Token {
QString id;
QString displayId;
QString name;
};
struct DecodeCache {
QByteArray idl;
QByteArray readsSignature;
WalletDecodeResult result;
};
explicit State(Decoder decoderFunction)
: decoder(decoderFunction ? std::move(decoderFunction)
: Decoder(WalletIdlDecoder::decode))
{
}
WalletDecodeResult decode(const QString& programId,
const Program& program,
const QVector<WalletAccountRead>& reads)
{
const QByteArray signature = accountReadsSignature(reads);
const auto cached = decodedPrograms.constFind(programId);
if (cached != decodedPrograms.cend()
&& cached->idl == program.idl
&& cached->readsSignature == signature) {
return cached->result;
}
WalletDecodeResult result = decoder(program.idl, reads);
decodedPrograms.insert(programId, { program.idl, signature, result });
return result;
}
Decoder decoder;
QHash<QString, Program> programs;
QHash<QString, DecodeCache> decodedPrograms;
};
WalletPortfolioService::WalletPortfolioService(Decoder decoder)
: m_state(std::make_unique<State>(std::move(decoder)))
{
}
WalletPortfolioService::~WalletPortfolioService() = default;
void WalletPortfolioService::registerProgram(const QString& programId,
const QString& programName,
const QByteArray& idlJson)
{
if (programId.isEmpty() || programName.isEmpty() || idlJson.isEmpty())
return;
const auto existing = m_state->programs.constFind(programId);
if (existing != m_state->programs.cend()
&& existing->name == programName
&& existing->idl == idlJson) {
return;
}
m_state->programs.insert(programId, { programName, idlJson });
m_state->decodedPrograms.remove(programId);
}
WalletPortfolioResult WalletPortfolioService::refresh(
const WalletPortfolioRequest& request)
{
if (request.walletFailure != WalletFailure::None)
return failureResult(QStringLiteral("error"), walletFailureCode(request.walletFailure));
if (request.tokenProgramId.isEmpty() || request.tokenDefinitionIds.isEmpty()) {
return failureResult(
QStringLiteral("blocked"), QStringLiteral("network_context_missing"));
}
if (request.tokenIdl.isEmpty())
return failureResult(QStringLiteral("error"), QStringLiteral("token_idl_missing"));
QVector<State::Token> tokens;
QSet<QString> resolvedIds;
QSet<QString> resolvedTokenIds;
for (const QVariant& value : request.tokens) {
const QVariantMap row = value.toMap();
const QString id = row.value(QStringLiteral("definitionIdHex")).toString();
const QString displayId = row.value(QStringLiteral("definitionId")).toString();
if (id.isEmpty() || displayId.isEmpty()
|| resolvedTokenIds.contains(id.toLower())) {
continue;
}
const bool requested = std::any_of(
request.tokenDefinitionIds.cbegin(),
request.tokenDefinitionIds.cend(),
[&id, &displayId](const QString& expected) {
return expected == displayId
|| (expected.size() == id.size()
&& expected.compare(id, Qt::CaseInsensitive) == 0);
});
if (!requested) {
continue;
}
QString name = row.value(QStringLiteral("name")).toString().trimmed();
if (name.isEmpty())
name = QStringLiteral("Unnamed token");
tokens.append({ id, displayId, std::move(name) });
resolvedTokenIds.insert(id.toLower());
resolvedIds.insert(id);
resolvedIds.insert(displayId);
}
QHash<QString, State::Program> programs = m_state->programs;
programs.insert(request.tokenProgramId, {
request.tokenProgramName.isEmpty() ? QStringLiteral("Token")
: request.tokenProgramName,
request.tokenIdl,
});
QHash<QString, QString> tokenNames;
for (const State::Token& token : tokens)
tokenNames.insert(token.id, token.name);
WalletPortfolioResult result;
QHash<QString, QString> balances;
bool tokenHoldingFailure = false;
bool unreadPublicAccount = false;
bool programFailure = false;
for (auto program = programs.cbegin(); program != programs.cend(); ++program) {
QVector<WalletAccountRead> programReads;
for (const WalletAccountRead& read : request.publicAccountReads) {
if (!read.ok()) {
unreadPublicAccount = true;
continue;
}
if (read.programOwner == program.key())
programReads.append(read);
}
if (programReads.isEmpty())
continue;
const WalletDecodeResult decoded = m_state->decode(
program.key(), program.value(), programReads);
if (!decoded.ok()) {
programFailure = true;
if (program.key() == request.tokenProgramId)
tokenHoldingFailure = true;
continue;
}
if (decoded.accounts.size() != programReads.size()) {
programFailure = true;
if (program.key() == request.tokenProgramId)
tokenHoldingFailure = true;
}
const qsizetype count = std::min(decoded.accounts.size(), programReads.size());
for (qsizetype index = 0; index < count; ++index) {
const WalletDecodedAccount& account = decoded.accounts.at(index);
const WalletAccountRead& read = programReads.at(index);
if (account.id != read.accountId) {
programFailure = true;
if (program.key() == request.tokenProgramId)
tokenHoldingFailure = true;
continue;
}
WalletAccountPresentation presentation;
presentation.address = read.accountId;
presentation.programName = program.value().name;
presentation.accountType = account.typeName;
if (account.status == QStringLiteral("decoded"))
presentation.decodedData = decodedDataText(account.value);
if (program.key() == request.tokenProgramId
&& account.typeName == QStringLiteral("TokenHolding")) {
const QJsonObject fungible = enumFields(account.value, QStringLiteral("Fungible"));
const QString encodedDefinitionId = fungible.value(
QStringLiteral("definition_id")).toString();
const QString definitionId = account.accountIds.value(encodedDefinitionId);
const QString amount = fungible.value(QStringLiteral("balance")).toString();
const QString total = decimalAdd(
balances.value(definitionId, QStringLiteral("0")), amount);
if (account.status != QStringLiteral("decoded")
|| fungible.isEmpty()
|| definitionId.isEmpty()
|| total.isEmpty()) {
tokenHoldingFailure = true;
} else {
balances.insert(definitionId, total);
}
presentation.kind = QStringLiteral("token_holding");
presentation.definitionId = definitionId;
presentation.hiddenFromAccounts = true;
const QString tokenName = tokenNames.value(definitionId);
if (!tokenName.isEmpty())
presentation.semanticName = tokenName + QStringLiteral(" holding");
} else if (program.key() == request.tokenProgramId
&& account.typeName == QStringLiteral("TokenDefinition")) {
presentation.kind = QStringLiteral("token_definition");
presentation.semanticName = enumFields(
account.value, QStringLiteral("Fungible"))
.value(QStringLiteral("name")).toString();
} else if (program.key() == request.tokenProgramId
&& account.typeName == QStringLiteral("TokenMetadata")) {
presentation.kind = QStringLiteral("token_metadata");
} else {
presentation.kind = QStringLiteral("program");
presentation.semanticName = account.typeName;
}
result.presentations.append(std::move(presentation));
}
}
QVariantList available;
auto appendAsset = [&result, &available](const QString& id,
const QString& displayId,
const QString& name,
const QString& programOwner,
const QString& balance,
bool unavailable) {
const bool positive = !unavailable
&& balance != QStringLiteral("0") && !balance.isEmpty();
QVariantMap asset {
{ QStringLiteral("name"), name },
{ QStringLiteral("symbol"), name },
{ QStringLiteral("balance"), unavailable ? QString() : balance },
{ QStringLiteral("definitionId"), id },
{ QStringLiteral("displayDefinitionId"), displayId },
{ QStringLiteral("programOwner"), programOwner },
{ QStringLiteral("status"), unavailable ? QStringLiteral("unavailable")
: QStringLiteral("ready") },
{ QStringLiteral("section"), positive ? QStringLiteral("assets")
: QStringLiteral("available") },
};
if (positive)
result.assets.append(std::move(asset));
else
available.append(std::move(asset));
};
const bool balancesUnavailable = tokenHoldingFailure || unreadPublicAccount;
for (const State::Token& token : tokens) {
appendAsset(token.id,
token.displayId,
token.name,
request.tokenProgramId,
balances.value(token.id, QStringLiteral("0")),
balancesUnavailable);
}
QSet<QString> missingIds;
for (const QString& id : request.tokenDefinitionIds) {
if (resolvedIds.contains(id)
|| resolvedTokenIds.contains(id.toLower())
|| missingIds.contains(id)) {
continue;
}
missingIds.insert(id);
appendAsset(id,
id,
QStringLiteral("Unknown token"),
{},
{},
true);
}
result.assets.append(available);
if (tokens.isEmpty()) {
result.status = QStringLiteral("error");
result.error = QStringLiteral("definitions_unavailable");
} else if (!missingIds.isEmpty() || tokenHoldingFailure || unreadPublicAccount) {
result.status = QStringLiteral("partial");
result.error = tokenHoldingFailure
? QStringLiteral("holding_decode_failed")
: unreadPublicAccount
? QStringLiteral("public_account_read_failed")
: QStringLiteral("some_definitions_unavailable");
} else if (programFailure) {
result.status = QStringLiteral("partial");
result.error = QStringLiteral("program_decode_failed");
} else {
result.status = QStringLiteral("ready");
}
return result;
}
@@ -0,0 +1,67 @@
#pragma once
#include <functional>
#include <memory>
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <QVariantList>
#include <QVector>
#include "WalletAccountModel.h"
#include "WalletIdlDecoder.h"
#include "WalletProvider.h"
// Input for a portfolio refresh. The snapshot constructor deliberately copies
// only `WalletSnapshot::publicAccountReads`; callers must not reconstruct reads
// from the display-model accounts.
struct WalletPortfolioRequest {
WalletPortfolioRequest() = default;
explicit WalletPortfolioRequest(const WalletSnapshot& snapshot)
: walletFailure(snapshot.failure),
publicAccountReads(snapshot.publicAccountReads)
{
}
WalletFailure walletFailure = WalletFailure::None;
QVector<WalletAccountRead> publicAccountReads;
QStringList tokenDefinitionIds;
QVariantList tokens;
QString tokenProgramId;
QByteArray tokenIdl;
QString tokenProgramName = QStringLiteral("Token");
};
struct WalletPortfolioResult {
QVector<WalletAccountPresentation> presentations;
QVariantList assets;
QString status = QStringLiteral("idle");
QString error;
};
// Presents accounts owned by registered IDL programs and combines decoded token
// holdings with token definitions already resolved by the token module.
class WalletPortfolioService final {
public:
using Decoder = std::function<WalletDecodeResult(
const QByteArray&, const QVector<WalletAccountRead>&)>;
explicit WalletPortfolioService(Decoder decoder = {});
~WalletPortfolioService();
WalletPortfolioService(const WalletPortfolioService&) = delete;
WalletPortfolioService& operator=(const WalletPortfolioService&) = delete;
// Adds an IDL-backed account presentation. Later registrations for the
// same program id replace the prior definition.
void registerProgram(const QString& programId,
const QString& programName,
const QByteArray& idlJson);
WalletPortfolioResult refresh(const WalletPortfolioRequest& request);
private:
struct State;
std::unique_ptr<State> m_state;
};
+10
View File
@@ -1,5 +1,6 @@
#pragma once
#include <functional>
#include <QString>
#include <QStringList>
#include <QVector>
@@ -38,6 +39,10 @@ struct WalletAccount {
QString address;
QString balance;
bool isPublic = true;
QString readStatus;
QString programOwner;
QString dataHex;
QString displayAddress;
};
struct WalletSnapshot {
@@ -92,12 +97,17 @@ struct WalletSubmission {
class WalletProvider {
public:
using SessionCallback = std::function<void(WalletSession)>;
using SnapshotCallback = std::function<void(WalletSnapshot)>;
virtual ~WalletProvider() = default;
virtual WalletSession connect(const WalletPaths& paths) = 0;
virtual void connectAsync(const WalletPaths& paths, SessionCallback callback) = 0;
virtual WalletCreation createWallet(const WalletPaths& paths,
const QString& password) = 0;
virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0;
virtual void snapshotAsync(bool forceRefresh, SnapshotCallback callback) = 0;
virtual void clearSnapshot() = 0;
virtual WalletAccountCreation createAccount(bool isPublic) = 0;
virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 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 "WalletAccountModel.h"
@@ -17,7 +24,33 @@
namespace {
const QString ACCOUNT_A(64, QLatin1Char('a'));
const QString ACCOUNT_B(64, QLatin1Char('b'));
const QString ACCOUNT_C(64, QLatin1Char('d'));
const QString PROGRAM_ID(64, QLatin1Char('c'));
const QString EOA_OWNER(64, QLatin1Char('0'));
class ScopedEnvironment final {
public:
ScopedEnvironment(QByteArray name, QByteArray value)
: m_name(std::move(name)),
m_hadValue(qEnvironmentVariableIsSet(m_name.constData())),
m_previous(qgetenv(m_name.constData()))
{
qputenv(m_name.constData(), value);
}
~ScopedEnvironment()
{
if (m_hadValue)
qputenv(m_name.constData(), m_previous);
else
qunsetenv(m_name.constData());
}
private:
QByteArray m_name;
bool m_hadValue;
QByteArray m_previous;
};
QString publicAccountJson(const QString& owner = PROGRAM_ID,
const QString& balance = QStringLiteral("01000000000000000000000000000000"),
@@ -39,6 +72,7 @@ QVariantMap accountEntry(const QString& id, bool isPublic)
{ QStringLiteral("is_public"), isPublic },
};
}
}
class LogosWalletProviderTest : public QObject {
@@ -47,18 +81,33 @@ class LogosWalletProviderTest : public QObject {
private slots:
void adoptsOpenWalletAndCachesSnapshots();
void opensConfiguredWalletWhenNoSharedSessionExists();
void opensStoredWalletAsynchronouslyWithoutAccountProbe();
void opensAndReadsAsynchronously();
void avoidsSavingAfterUnchangedAsynchronousSnapshots();
void createsAndPersistsWallet();
void validatesCompletePublicAccountPayloads();
void fallsBackToBalanceWhenPublicReadFails();
void createsAndPersistsAccounts();
void preservesCreatedAccountWhenPublicReadFails();
void preservesCreatedAccountWhenSnapshotRefreshFails();
void createdAccountDoesNotRescanWallet();
void dispatchesExactGenericTransaction();
void rejectsInvalidSubmissionResponses();
void exposesStableAccountModelRoles();
void clearsStaleAccountPresentationsWithoutInvalidatingPrimary();
void persistsHumanizedWalletPreferences();
void fakeProviderImplementsConsumerContract();
void controllerOwnsUiWalletFlow();
void controllerSeparatesSnapshotsFromCosmeticState();
void controllerOpenDoesNotWaitForWalletSync();
void controllerCreationDoesNotWaitForWalletSync();
void controllerSeedsDefaultWalletConfigWithConfiguredEndpoint();
void controllerPreservesExistingDefaultWalletConfig();
void controllerStopsReachabilityChecksAfterDisconnect();
void completedAsyncSnapshotReleasesCallback();
void deferredCallbacksIgnoreDestroyedController();
void newerReachabilityResultWins();
void coalescesReachabilityChecksForSameEndpoint();
void controllerReportsPartialWalletCreation();
};
void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
@@ -84,11 +133,18 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1"));
QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42"));
QCOMPARE(session.snapshot.publicAccountReads.size(), 1);
QCOMPARE(session.snapshot.accounts.at(0).readStatus, QStringLiteral("ok"));
QCOMPARE(session.snapshot.accounts.at(0).programOwner, PROGRAM_ID);
QCOMPARE(session.snapshot.accounts.at(0).dataHex, QStringLiteral("00ff"));
QCOMPARE(session.snapshot.accounts.at(0).displayAddress,
QStringLiteral("base58-") + ACCOUNT_A);
QCOMPARE(session.snapshot.accounts.at(1).readStatus, QStringLiteral("private"));
QCOMPARE(session.snapshot.currentBlockHeight, quint64(12));
QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11));
const int listCalls = modules.logos_execution_zone.listCalls;
const int readCalls = modules.logos_execution_zone.publicReadCalls;
const int saveCalls = modules.logos_execution_zone.saveCalls;
QVERIFY(provider.snapshot().ok());
QCOMPARE(modules.logos_execution_zone.listCalls, listCalls);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls);
@@ -96,6 +152,7 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
QVERIFY(provider.snapshot(true).ok());
QVERIFY(modules.logos_execution_zone.listCalls > listCalls);
QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls);
QCOMPARE(modules.logos_execution_zone.saveCalls, saveCalls);
modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(
PROGRAM_ID, QString(32, QLatin1Char('f')));
@@ -131,6 +188,7 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists()
QVERIFY(!session.adopted);
QCOMPARE(modules.logos_execution_zone.openCalls, 1);
QCOMPARE(modules.logos_execution_zone.openedStorage, storage);
QCOMPARE(modules.logos_execution_zone.listCalls, 1);
LogosModules missingModules;
LogosWalletProvider missingProvider(&missingModules);
@@ -138,12 +196,83 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists()
WalletFailure::WalletMissing);
}
void LogosWalletProviderTest::opensStoredWalletAsynchronouslyWithoutAccountProbe()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString storage = directory.filePath(QStringLiteral("storage.json"));
QFile file(storage);
QVERIFY(file.open(QIODevice::WriteOnly));
file.close();
LogosModules modules;
LogosWalletProvider provider(&modules);
bool connected = false;
provider.connectAsync({ directory.filePath(QStringLiteral("wallet.json")), storage },
[&connected](WalletSession session) {
connected = session.ok() && !session.adopted;
});
QVERIFY(connected);
QCOMPARE(modules.logos_execution_zone.openCalls, 1);
QCOMPARE(modules.logos_execution_zone.listCalls, 1);
}
void LogosWalletProviderTest::opensAndReadsAsynchronously()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
modules.logos_execution_zone.publicAccounts.insert(
ACCOUNT_A, publicAccountJson(EOA_OWNER));
LogosWalletProvider provider(&modules);
bool connected = false;
provider.connectAsync({}, [&connected](WalletSession session) {
connected = session.ok() && session.snapshot.accounts.size() == 1;
});
QVERIFY(connected);
bool refreshed = false;
provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) {
refreshed = snapshot.ok() && snapshot.accounts.at(0).programOwner == EOA_OWNER;
});
QVERIFY(refreshed);
}
void LogosWalletProviderTest::avoidsSavingAfterUnchangedAsynchronousSnapshots()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.currentBlockHeight = 12;
modules.logos_execution_zone.lastSyncedBlock = 12;
LogosWalletProvider provider(&modules);
bool connected = false;
provider.connectAsync({}, [&connected](WalletSession session) {
connected = session.ok();
});
QVERIFY(connected);
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
bool refreshed = false;
provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) {
refreshed = snapshot.ok();
});
QVERIFY(refreshed);
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
}
void LogosWalletProviderTest::createsAndPersistsWallet()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
LogosModules modules;
modules.logos_execution_zone.currentBlockHeight = 12;
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson());
LogosWalletProvider provider(&modules);
const WalletPaths paths {
directory.filePath(QStringLiteral("config/wallet.json")),
@@ -157,6 +286,9 @@ void LogosWalletProviderTest::createsAndPersistsWallet()
QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage);
QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret"));
QVERIFY(modules.logos_execution_zone.saveCalls >= 1);
QCOMPARE(modules.logos_execution_zone.syncCalls, 0);
QCOMPARE(modules.logos_execution_zone.listCalls, 0);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0);
LogosModules rejectedModules;
rejectedModules.logos_execution_zone.mnemonic.clear();
@@ -227,12 +359,14 @@ void LogosWalletProviderTest::createsAndPersistsAccounts()
QVERIFY(provider.connect({}).ok());
const int savesBeforeCreate = modules.logos_execution_zone.saveCalls;
const int publicReadsBeforeCreate = modules.logos_execution_zone.publicReadCalls;
const WalletAccountCreation creation = provider.createAccount(true);
QVERIFY(creation.ok());
QCOMPARE(creation.accountId, ACCOUNT_A);
QVERIFY(creation.publicAccount.ok());
QCOMPARE(creation.snapshot.accounts.size(), 1);
QVERIFY(modules.logos_execution_zone.saveCalls > savesBeforeCreate);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, publicReadsBeforeCreate + 1);
modules.logos_execution_zone.saveResult = 1;
QCOMPARE(provider.createAccount(true).failure, WalletFailure::SaveFailed);
@@ -257,7 +391,7 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenPublicReadFails()
QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7"));
}
void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails()
void LogosWalletProviderTest::createdAccountDoesNotRescanWallet()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
@@ -268,11 +402,13 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails()
modules.logos_execution_zone.currentBlockHeight = 1;
modules.logos_execution_zone.syncResult = 1;
const int syncCalls = modules.logos_execution_zone.syncCalls;
const WalletAccountCreation creation = provider.createAccount(true);
QVERIFY(creation.ok());
QCOMPARE(creation.accountId, ACCOUNT_A);
QCOMPARE(creation.snapshot.failure, WalletFailure::ReadFailed);
QVERIFY(creation.snapshot.ok());
QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls);
}
void LogosWalletProviderTest::dispatchesExactGenericTransaction()
@@ -298,8 +434,8 @@ void LogosWalletProviderTest::dispatchesExactGenericTransaction()
QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds);
QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements,
QVariantList({ true, false }));
QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(),
QVariantList({ 7U, 0U, 4294967295U }));
QCOMPARE(modules.logos_execution_zone.submittedInstruction.toByteArray(),
QByteArray::fromHex("0700000000000000ffffffff"));
}
void LogosWalletProviderTest::rejectsInvalidSubmissionResponses()
@@ -338,19 +474,171 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles()
WalletAccountModel model;
QSignalSpy countChanged(&model, &WalletAccountModel::countChanged);
model.replaceAccounts({
{ ACCOUNT_A, QStringLiteral("10"), true },
{ ACCOUNT_B, QStringLiteral("20"), false },
});
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
{ ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} },
{ ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, QStringLiteral("00") },
}, { { ACCOUNT_A, QStringLiteral("Trading") } }, ACCOUNT_A);
QCOMPARE(model.count(), 2);
QCOMPARE(model.count(), 3);
QCOMPARE(countChanged.count(), 1);
QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name"));
QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(),
QStringLiteral("Account 1"));
QStringLiteral("Trading"));
QCOMPARE(model.data(model.index(0), WalletAccountModel::KindRole).toString(),
QStringLiteral("user"));
QVERIFY(model.data(model.index(0), WalletAccountModel::CanBePrimaryRole).toBool());
QVERIFY(model.data(model.index(0), WalletAccountModel::IsPrimaryRole).toBool());
QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B);
QCOMPARE(model.roleNames().value(WalletAccountModel::DisplayAddressRole),
QByteArray("displayAddress"));
QCOMPARE(model.roleNames().value(WalletAccountModel::DecodedDataRole),
QByteArray("decodedData"));
QCOMPARE(model.data(model.index(1), WalletAccountModel::DisplayAddressRole).toString(),
ACCOUNT_B);
QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(),
QStringLiteral("20"));
QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool());
QCOMPARE(model.data(model.index(2), WalletAccountModel::KindRole).toString(),
QStringLiteral("program"));
QVERIFY(!model.data(model.index(2), WalletAccountModel::CanBePrimaryRole).toBool());
QSignalSpy presentationsChanged(&model, &QAbstractItemModel::dataChanged);
const QVector<WalletAccountPresentation> presentations {
{
ACCOUNT_A,
QStringLiteral("program"),
{},
QStringLiteral("System"),
QStringLiteral("UserAccount"),
{},
false,
QStringLiteral("{\"public_key\":\"Public/test\"}"),
},
{
ACCOUNT_C,
QStringLiteral("token_holding"),
QStringLiteral("TEST holding"),
QStringLiteral("Token"),
QStringLiteral("TokenHolding"),
ACCOUNT_A,
true,
},
};
model.applyPresentations(presentations);
QCOMPARE(presentationsChanged.count(), 1);
QCOMPARE(model.data(model.index(2), WalletAccountModel::SectionRole).toString(),
QStringLiteral("hidden"));
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
QStringLiteral("TEST holding"));
QCOMPARE(model.data(model.index(0), WalletAccountModel::DecodedDataRole).toString(),
QStringLiteral("{\"public_key\":\"Public/test\"}"));
model.setAlias(ACCOUNT_C, QStringLiteral("Reserve"));
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
QStringLiteral("Reserve"));
model.setAlias(ACCOUNT_C, {});
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
QStringLiteral("TEST holding"));
QSignalSpy redundantPresentation(&model, &QAbstractItemModel::dataChanged);
QVERIFY(!model.applyPresentations(presentations));
QCOMPARE(redundantPresentation.count(), 0);
}
void LogosWalletProviderTest::clearsStaleAccountPresentationsWithoutInvalidatingPrimary()
{
const QString settingsApplication = QStringLiteral("WalletPresentationClearingTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.connectResult.adopted = true;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
{ ACCOUNT_C, QStringLiteral("20"), true, QStringLiteral("ok"), PROGRAM_ID,
QStringLiteral("00ff") },
};
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("User account"));
controller.applyAccountPresentations({
{
ACCOUNT_C,
QStringLiteral("token_holding"),
QStringLiteral("TEST holding"),
QStringLiteral("Token"),
QStringLiteral("TokenHolding"),
ACCOUNT_A,
true,
QStringLiteral("{\"amount\":\"20\"}"),
},
});
WalletAccountModel* model = controller.accountModel();
const QModelIndex holding = model->index(model->indexOf(ACCOUNT_C));
QCOMPARE(model->data(holding, WalletAccountModel::KindRole).toString(),
QStringLiteral("token_holding"));
QCOMPARE(model->data(holding, WalletAccountModel::SectionRole).toString(),
QStringLiteral("hidden"));
QCOMPARE(model->data(holding, WalletAccountModel::NameRole).toString(),
QStringLiteral("TEST holding"));
QCOMPARE(model->data(holding, WalletAccountModel::DecodedDataRole).toString(),
QStringLiteral("{\"amount\":\"20\"}"));
controller.applyAccountPresentations({});
QCOMPARE(model->data(holding, WalletAccountModel::KindRole).toString(),
QStringLiteral("program"));
QCOMPARE(model->data(holding, WalletAccountModel::SectionRole).toString(),
QStringLiteral("advanced"));
QCOMPARE(model->data(holding, WalletAccountModel::NameRole).toString(),
QStringLiteral("Program account"));
QCOMPARE(model->data(holding, WalletAccountModel::ProgramNameRole).toString(), QString());
QCOMPARE(model->data(holding, WalletAccountModel::AccountTypeRole).toString(), QString());
QCOMPARE(model->data(holding, WalletAccountModel::DefinitionIdRole).toString(), QString());
QCOMPARE(model->data(holding, WalletAccountModel::DecodedDataRole).toString(), QString());
QVERIFY(!model->data(holding, WalletAccountModel::CanBePrimaryRole).toBool());
const QModelIndex primary = model->index(model->indexOf(ACCOUNT_A));
QVERIFY(model->data(primary, WalletAccountModel::CanBePrimaryRole).toBool());
QVERIFY(model->data(primary, WalletAccountModel::IsPrimaryRole).toBool());
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("User account"));
settings.clear();
}
void LogosWalletProviderTest::persistsHumanizedWalletPreferences()
{
const QString application = QStringLiteral("HumanizedWalletPreferencesTest");
QSettings settings(QStringLiteral("Logos"), application);
settings.clear();
FakeWalletProvider provider;
provider.connectResult.adopted = true;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
{ ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} },
{ ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, {} },
};
{
WalletController controller(provider, application);
QVERIFY(controller.open());
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
QVERIFY(!controller.setPrimaryAccount(ACCOUNT_C));
QVERIFY(controller.setAccountAlias(ACCOUNT_B, QStringLiteral(" Private savings ")));
QVERIFY(controller.setPrimaryAccount(ACCOUNT_B));
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("Private savings"));
QVERIFY(!controller.setAccountAlias(ACCOUNT_A, QString(41, QLatin1Char('x'))));
}
WalletController reopened(provider, application);
QVERIFY(reopened.open());
QCOMPARE(reopened.state().primaryAccountAddress, ACCOUNT_B);
QCOMPARE(reopened.state().primaryAccountName, QStringLiteral("Private savings"));
settings.clear();
}
void LogosWalletProviderTest::fakeProviderImplementsConsumerContract()
@@ -419,6 +707,164 @@ void LogosWalletProviderTest::controllerOwnsUiWalletFlow()
settings.clear();
}
void LogosWalletProviderTest::controllerSeparatesSnapshotsFromCosmeticState()
{
const QString settingsApplication = QStringLiteral("WalletSnapshotSignalTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("5"), true },
};
provider.snapshotResult = provider.connectResult.snapshot;
WalletController controller(provider, settingsApplication);
QSignalSpy stateChanged(&controller, &WalletController::stateChanged);
QSignalSpy snapshotChanged(&controller, &WalletController::snapshotChanged);
QVERIFY(controller.open());
stateChanged.clear();
snapshotChanged.clear();
QVERIFY(controller.setAccountAlias(ACCOUNT_A, QStringLiteral("Spending")));
QCOMPARE(stateChanged.count(), 1);
QCOMPARE(snapshotChanged.count(), 0);
controller.refresh();
QCOMPARE(stateChanged.count(), 3);
QCOMPARE(snapshotChanged.count(), 1);
settings.clear();
}
void LogosWalletProviderTest::controllerOpenDoesNotWaitForWalletSync()
{
const QString settingsApplication = QStringLiteral("WalletAsyncOpenTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("5"), true },
};
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QCOMPARE(provider.connectCalls, 1);
QVERIFY(!controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("opening"));
QCOMPARE(controller.accountModel()->count(), 0);
provider.finishConnect();
QVERIFY(controller.state().isWalletOpen);
QVERIFY(controller.state().canSubmit());
QCOMPARE(controller.state().syncStatus, QStringLiteral("ready"));
QCOMPARE(controller.accountModel()->count(), 1);
settings.clear();
}
void LogosWalletProviderTest::controllerCreationDoesNotWaitForWalletSync()
{
const QString settingsApplication = QStringLiteral("WalletAsyncCreationTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
provider.snapshotResult.accounts = {
{ ACCOUNT_A, QStringLiteral("5"), true },
};
WalletController controller(provider, settingsApplication);
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QCOMPARE(provider.createWalletCalls, 1);
QCOMPARE(provider.snapshotCalls, 0);
QVERIFY(controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing"));
QVERIFY(!controller.state().canSubmit());
QCOMPARE(controller.accountModel()->count(), 0);
QTRY_COMPARE(provider.snapshotCalls, 1);
QVERIFY(provider.lastForceRefresh);
QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing"));
provider.finishSnapshot();
QCOMPARE(controller.state().syncStatus, QStringLiteral("ready"));
QVERIFY(controller.state().canSubmit());
QCOMPARE(controller.accountModel()->count(), 1);
settings.clear();
}
void LogosWalletProviderTest::controllerSeedsDefaultWalletConfigWithConfiguredEndpoint()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString walletHome = directory.filePath(QStringLiteral("wallet"));
ScopedEnvironment walletHomeEnvironment(
QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit());
const QString settingsApplication = QStringLiteral("WalletDefaultEndpointTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
WalletController controller(provider, settingsApplication);
controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/"));
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
const QString configPath = walletHome + QStringLiteral("/wallet_config.json");
QCOMPARE(provider.lastPaths.config, configPath);
QFile config(configPath);
QVERIFY(config.open(QIODevice::ReadOnly));
const QJsonDocument document = QJsonDocument::fromJson(config.readAll());
QVERIFY(document.isObject());
const QJsonObject values = document.object();
QCOMPARE(values.value(QStringLiteral("sequencer_addr")).toString(),
QStringLiteral("https://testnet.lez.logos.co/"));
QCOMPARE(values.value(QStringLiteral("seq_poll_timeout")).toString(),
QStringLiteral("12s"));
QCOMPARE(values.value(QStringLiteral("seq_tx_poll_max_blocks")).toInt(), 5);
QCOMPARE(values.value(QStringLiteral("seq_poll_max_retries")).toInt(), 5);
QCOMPARE(values.value(QStringLiteral("seq_block_poll_max_amount")).toInt(), 100);
settings.clear();
}
void LogosWalletProviderTest::controllerPreservesExistingDefaultWalletConfig()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString walletHome = directory.filePath(QStringLiteral("wallet"));
QVERIFY(QDir().mkpath(walletHome));
const QString configPath = walletHome + QStringLiteral("/wallet_config.json");
const QByteArray existingConfig = QByteArrayLiteral(
"{\"sequencer_addr\":\"http://127.0.0.1:3040/\",\"custom\":true}");
QFile config(configPath);
QVERIFY(config.open(QIODevice::WriteOnly));
QCOMPARE(config.write(existingConfig), qint64(existingConfig.size()));
config.close();
ScopedEnvironment walletHomeEnvironment(
QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit());
const QString settingsApplication = QStringLiteral("WalletExistingEndpointTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
WalletController controller(provider, settingsApplication);
controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/"));
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QVERIFY(config.open(QIODevice::ReadOnly));
QCOMPARE(config.readAll(), existingConfig);
settings.clear();
}
void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityTest");
@@ -434,19 +880,164 @@ void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect()
QVERIFY(controller.open());
QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000);
controller.disconnect();
finished.clear();
auto* timer = controller.findChild<QTimer*>();
QVERIFY(timer);
QVERIFY(timer->isActive());
controller.disconnect();
QVERIFY(!timer->isActive());
finished.clear();
timer->setInterval(1);
controller.start();
QTest::qWait(50);
QVERIFY(!timer->isActive());
QCOMPARE(finished.count(), 0);
settings.clear();
}
void LogosWalletProviderTest::completedAsyncSnapshotReleasesCallback()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
bool completed = false;
std::weak_ptr<int> callbackLifetime;
{
auto lifetime = std::make_shared<int>(1);
callbackLifetime = lifetime;
provider.snapshotAsync(true,
[lifetime = std::move(lifetime), &completed](WalletSnapshot snapshot) {
QVERIFY(snapshot.ok());
completed = true;
});
}
QVERIFY(completed);
QVERIFY(callbackLifetime.expired());
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
}
void LogosWalletProviderTest::deferredCallbacksIgnoreDestroyedController()
{
const QString settingsApplication = QStringLiteral("WalletDestroyedCallbackTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
{
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
QVERIFY(controller->open());
}
provider.finishConnect();
provider.deferAsync = false;
{
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
QVERIFY(controller->open());
provider.deferAsync = true;
controller->refresh();
}
provider.finishSnapshot();
settings.clear();
}
void LogosWalletProviderTest::newerReachabilityResultWins()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityOrderTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
QTcpServer firstServer;
QTcpServer secondServer;
QVERIFY(firstServer.listen(QHostAddress::LocalHost));
QVERIFY(secondServer.listen(QHostAddress::LocalHost));
const QString firstEndpoint = QStringLiteral("http://127.0.0.1:%1")
.arg(firstServer.serverPort());
const QString secondEndpoint = QStringLiteral("http://127.0.0.1:%1")
.arg(secondServer.serverPort());
FakeWalletProvider provider;
provider.connectResult.snapshot.sequencerAddress = firstEndpoint;
WalletController controller(provider, settingsApplication);
auto* network = controller.findChild<QNetworkAccessManager*>();
QVERIFY(network);
QSignalSpy finished(network, &QNetworkAccessManager::finished);
QVERIFY(controller.open());
QTRY_VERIFY(firstServer.hasPendingConnections());
QTcpSocket* first = firstServer.nextPendingConnection();
QVERIFY(first);
provider.createAccountResult.accountId = ACCOUNT_B;
provider.createAccountResult.snapshot.sequencerAddress = secondEndpoint;
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
QTRY_VERIFY(secondServer.hasPendingConnections());
QTcpSocket* second = secondServer.nextPendingConnection();
QVERIFY(second);
second->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
second->disconnectFromHost();
QTRY_COMPARE(finished.size(), 1);
QVERIFY(controller.state().sequencerReachable);
first->disconnectFromHost();
QTRY_COMPARE(finished.size(), 2);
QVERIFY(controller.state().sequencerReachable);
settings.clear();
}
void LogosWalletProviderTest::coalescesReachabilityChecksForSameEndpoint()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityCoalesceTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
QTcpServer server;
QVERIFY(server.listen(QHostAddress::LocalHost));
const QString endpoint = QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort());
FakeWalletProvider provider;
provider.connectResult.snapshot.sequencerAddress = endpoint;
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QTRY_VERIFY(server.hasPendingConnections());
QTcpSocket* request = server.nextPendingConnection();
QVERIFY(request);
provider.createAccountResult.accountId = ACCOUNT_B;
provider.createAccountResult.snapshot.sequencerAddress = endpoint;
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
QTest::qWait(50);
QVERIFY(!server.hasPendingConnections());
request->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
request->disconnectFromHost();
settings.clear();
}
void LogosWalletProviderTest::controllerReportsPartialWalletCreation()
{
const QString settingsApplication = QStringLiteral("WalletPartialCreationTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
provider.createWalletResult.failure = WalletFailure::SaveFailed;
WalletController controller(provider, settingsApplication);
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QVERIFY(!controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("error"));
QCOMPARE(controller.state().syncError, QStringLiteral("save_failed"));
settings.clear();
}
QTEST_GUILESS_MAIN(LogosWalletProviderTest)
#include "LogosWalletProviderTest.moc"
@@ -0,0 +1,328 @@
#include "SequencerIdentityProbe.h"
#include <QHostAddress>
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QList>
#include <QPointer>
#include <QQueue>
#include <QSignalSpy>
#include <QTcpServer>
#include <QTcpSocket>
#include <QUrl>
#include <QtTest>
#include <utility>
namespace {
const QString IDENTITY(64, QLatin1Char('a'));
const QString OTHER_IDENTITY(64, QLatin1Char('b'));
SequencerNetworkContext::Configuration networkConfiguration()
{
return {
QStringLiteral("testnet"),
IDENTITY,
QStringLiteral("checkpoint:"),
};
}
QByteArray jsonRpcResult(const QString& identity)
{
return QJsonDocument(QJsonObject {
{ QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
{ QStringLiteral("id"), 1 },
{ QStringLiteral("result"), identity },
}).toJson(QJsonDocument::Compact);
}
class RpcServer final : public QObject {
public:
RpcServer()
{
m_server.listen(QHostAddress::LocalHost);
connect(&m_server, &QTcpServer::newConnection, this, [this]() {
while (QTcpSocket* socket = m_server.nextPendingConnection())
attach(socket);
});
}
QUrl endpoint() const
{
return QUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()));
}
bool isListening() const { return m_server.isListening(); }
void enqueueResponse(int status, QByteArray body)
{
m_responses.enqueue({ status, std::move(body) });
}
void holdNextResponse()
{
m_responses.enqueue({ 0, {} });
}
void respondHeld(int status, QByteArray body)
{
while (!m_held.isEmpty()) {
const QPointer<QTcpSocket> socket = m_held.dequeue();
if (socket)
sendResponse(socket, status, std::move(body));
return;
}
}
int requestCount() const { return m_requests.size(); }
QByteArray lastRequest() const { return m_requests.isEmpty() ? QByteArray() : m_requests.last(); }
private:
struct Response {
int status;
QByteArray body;
};
void attach(QTcpSocket* socket)
{
socket->setParent(this);
connect(socket, &QTcpSocket::readyRead, this, [this, socket]() {
QByteArray& request = m_partialRequests[socket];
request.append(socket->readAll());
const qsizetype headerEnd = request.indexOf("\r\n\r\n");
if (headerEnd < 0)
return;
const QByteArray headers = request.left(headerEnd);
qsizetype contentLength = 0;
for (const QByteArray& line : headers.split('\n')) {
const qsizetype separator = line.indexOf(':');
if (separator < 0)
continue;
if (line.left(separator).trimmed().compare("content-length",
Qt::CaseInsensitive) == 0) {
contentLength = line.mid(separator + 1).trimmed().toLongLong();
break;
}
}
if (request.size() < headerEnd + 4 + contentLength)
return;
m_requests.append(request);
m_partialRequests.remove(socket);
if (m_responses.isEmpty()) {
m_held.enqueue(socket);
return;
}
const Response response = m_responses.dequeue();
if (response.status == 0) {
m_held.enqueue(socket);
return;
}
sendResponse(socket, response.status, response.body);
});
connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater);
}
static void sendResponse(QTcpSocket* socket, int status, QByteArray body)
{
const QByteArray statusText = status >= 200 && status < 300
? QByteArrayLiteral("OK") : QByteArrayLiteral("Service Unavailable");
QByteArray response = QByteArrayLiteral("HTTP/1.1 ") + QByteArray::number(status)
+ QByteArrayLiteral(" ") + statusText
+ QByteArrayLiteral("\r\nContent-Type: application/json\r\nContent-Length: ")
+ QByteArray::number(body.size())
+ QByteArrayLiteral("\r\nConnection: close\r\n\r\n") + body;
socket->write(response);
socket->flush();
socket->disconnectFromHost();
}
QTcpServer m_server;
QHash<QTcpSocket*, QByteArray> m_partialRequests;
QQueue<Response> m_responses;
QQueue<QPointer<QTcpSocket>> m_held;
QList<QByteArray> m_requests;
};
SequencerIdentityProbe::Request requestFor(const QUrl& endpoint)
{
return {
endpoint,
QStringLiteral("getChannelId"),
QJsonArray { 10 },
SequencerIdentityProbe::stringIdentity,
};
}
}
class SequencerIdentityProbeTest final : public QObject {
Q_OBJECT
private slots:
void sendsConfiguredRequestAndAcceptsIdentity();
void waitsForEndpointBeforeProbing();
void retriesRejectedResponses_data();
void retriesRejectedResponses();
void supersedesEndpointReply();
void abortsReplyWhenReachabilityIsLost();
void retriesTransportFailureAfterEndpointChanges();
void extractsCheckpointBlockHash();
};
void SequencerIdentityProbeTest::sendsConfiguredRequestAndAcceptsIdentity()
{
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
QCOMPARE(probe.snapshot().fingerprint, QStringLiteral("checkpoint:") + IDENTITY);
QCOMPARE(server.requestCount(), 1);
QVERIFY(server.lastRequest().contains("\"method\":\"getChannelId\""));
QVERIFY(server.lastRequest().contains("\"params\":[10]"));
}
void SequencerIdentityProbeTest::waitsForEndpointBeforeProbing()
{
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(QUrl())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QCOMPARE(probe.snapshot().status, QStringLiteral("network_unknown"));
QCOMPARE(server.requestCount(), 0);
QVERIFY(probe.setEndpoint(server.endpoint()));
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
QCOMPARE(server.requestCount(), 1);
}
void SequencerIdentityProbeTest::retriesRejectedResponses_data()
{
QTest::addColumn<int>("status");
QTest::addColumn<QByteArray>("body");
QTest::addColumn<QString>("failure");
QTest::newRow("http") << 503 << QByteArrayLiteral("{}")
<< QStringLiteral("http_status");
QTest::newRow("malformed") << 200 << QByteArrayLiteral("not-json")
<< QStringLiteral("malformed_response");
QTest::newRow("json-rpc-error") << 200
<< QByteArrayLiteral("{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-1}}")
<< QStringLiteral("json_rpc_error");
}
void SequencerIdentityProbeTest::retriesRejectedResponses()
{
QFETCH(int, status);
QFETCH(QByteArray, body);
QFETCH(QString, failure);
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(status, body);
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QSignalSpy failures(&probe, &SequencerIdentityProbe::probeFailed);
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(server.requestCount(), 2);
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
QVERIFY(!failures.isEmpty());
QCOMPARE(failures.first().at(0).toString(), failure);
}
void SequencerIdentityProbeTest::supersedesEndpointReply()
{
RpcServer first;
QVERIFY(first.isListening());
first.holdNextResponse();
RpcServer second;
QVERIFY(second.isListening());
second.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(first.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(first.requestCount(), 1);
QVERIFY(probe.setEndpoint(second.endpoint()));
QTRY_COMPARE(second.requestCount(), 1);
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
first.respondHeld(200, jsonRpcResult(OTHER_IDENTITY));
QTest::qWait(50);
QCOMPARE(probe.snapshot().status, QStringLiteral("ready"));
}
void SequencerIdentityProbeTest::abortsReplyWhenReachabilityIsLost()
{
RpcServer server;
QVERIFY(server.isListening());
server.holdNextResponse();
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(server.requestCount(), 1);
probe.setReachable(false);
server.respondHeld(200, jsonRpcResult(IDENTITY));
QTest::qWait(50);
QCOMPARE(probe.snapshot().status, QStringLiteral("network_unknown"));
}
void SequencerIdentityProbeTest::retriesTransportFailureAfterEndpointChanges()
{
QTcpServer unavailable;
QVERIFY(unavailable.listen(QHostAddress::LocalHost));
const QUrl unavailableEndpoint(
QStringLiteral("http://127.0.0.1:%1").arg(unavailable.serverPort()));
unavailable.close();
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QSignalSpy failures(&probe, &SequencerIdentityProbe::probeFailed);
QVERIFY(probe.configure(networkConfiguration(), requestFor(unavailableEndpoint)));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_VERIFY(!failures.isEmpty());
QCOMPARE(failures.first().at(0).toString(), QStringLiteral("transport_error"));
QVERIFY(probe.setEndpoint(server.endpoint()));
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
}
void SequencerIdentityProbeTest::extractsCheckpointBlockHash()
{
QByteArray block(72, '\0');
const QByteArray expected(32, static_cast<char>(0xab));
block.replace(40, expected.size(), expected);
QCOMPARE(SequencerIdentityProbe::checkpointBlockHash(
QJsonValue(QString::fromLatin1(block.toBase64()))),
QString::fromLatin1(expected.toHex()));
QVERIFY(SequencerIdentityProbe::checkpointBlockHash(QJsonValue(QStringLiteral("bad"))).isEmpty());
}
QTEST_MAIN(SequencerIdentityProbeTest)
#include "SequencerIdentityProbeTest.moc"
@@ -0,0 +1,96 @@
#include <QtTest>
#include "SequencerNetworkContext.h"
namespace {
const QString NETWORK_ID = QStringLiteral("testnet");
const QString IDENTITY(64, QLatin1Char('a'));
SequencerNetworkContext::Configuration configuration()
{
return {
NETWORK_ID,
IDENTITY,
QStringLiteral("checkpoint:"),
};
}
}
class SequencerNetworkContextTest final : public QObject {
Q_OBJECT
private slots:
void acceptsMatchingIdentity();
void rejectsLateReplyAfterReachabilityLoss();
void rejectsSupersededProbe();
void rejectsInvalidConfiguration();
};
void SequencerNetworkContextTest::acceptsMatchingIdentity()
{
SequencerNetworkContext context;
QVERIFY(context.configure(configuration()));
context.setSequencerAvailable(true);
context.setReachable(true);
const std::optional<quint64> probe = context.beginIdentityProbe();
QVERIFY(probe.has_value());
QVERIFY(context.finishIdentityProbe(*probe, IDENTITY));
QCOMPARE(context.snapshot().id, NETWORK_ID);
QCOMPARE(context.snapshot().status, QStringLiteral("ready"));
QCOMPARE(context.snapshot().fingerprint, QStringLiteral("checkpoint:") + IDENTITY);
}
void SequencerNetworkContextTest::rejectsLateReplyAfterReachabilityLoss()
{
SequencerNetworkContext context;
QVERIFY(context.configure(configuration()));
context.setSequencerAvailable(true);
context.setReachable(true);
const std::optional<quint64> probe = context.beginIdentityProbe();
QVERIFY(probe.has_value());
context.setReachable(false);
QVERIFY(!context.finishIdentityProbe(*probe, IDENTITY));
QCOMPARE(context.snapshot().status, QStringLiteral("network_unknown"));
QVERIFY(context.snapshot().fingerprint.isEmpty());
}
void SequencerNetworkContextTest::rejectsSupersededProbe()
{
SequencerNetworkContext context;
QVERIFY(context.configure(configuration()));
context.setSequencerAvailable(true);
context.setReachable(true);
const std::optional<quint64> firstProbe = context.beginIdentityProbe();
QVERIFY(firstProbe.has_value());
context.setReachable(false);
context.setReachable(true);
const std::optional<quint64> secondProbe = context.beginIdentityProbe();
QVERIFY(secondProbe.has_value());
QVERIFY(!context.finishIdentityProbe(*firstProbe, IDENTITY));
QVERIFY(context.finishIdentityProbe(*secondProbe, IDENTITY));
QCOMPARE(context.snapshot().status, QStringLiteral("ready"));
}
void SequencerNetworkContextTest::rejectsInvalidConfiguration()
{
SequencerNetworkContext context;
SequencerNetworkContext::Configuration invalid = configuration();
invalid.expectedIdentity = QString(64, QLatin1Char('A'));
QVERIFY(!context.configure(invalid));
QVERIFY(!context.isConfigured());
QCOMPARE(context.snapshot().id, NETWORK_ID);
QCOMPARE(context.snapshot().status, QStringLiteral("config_missing"));
}
QTEST_MAIN(SequencerNetworkContextTest)
#include "SequencerNetworkContextTest.moc"
@@ -0,0 +1,66 @@
#include "SequencerNetworkSettings.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryFile>
#include <QtTest>
class SequencerNetworkSettingsTest : public QObject {
Q_OBJECT
private slots:
void loadsBundledTestnetIdentity();
void loadsDevnetChannelIdentity();
void rejectsInvalidDevnetIdentity();
};
void SequencerNetworkSettingsTest::loadsBundledTestnetIdentity()
{
const auto settings = SequencerNetworkSettingsLoader::load(
QStringLiteral("testnet"), {});
QVERIFY(settings);
QCOMPARE(settings->context.id, QStringLiteral("testnet"));
QCOMPARE(settings->context.expectedIdentity,
QStringLiteral("0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a"));
QCOMPARE(settings->context.fingerprintPrefix, QStringLiteral("block10:"));
QCOMPARE(settings->identityMethod, SequencerIdentityMethod::CheckpointBlock);
}
void SequencerNetworkSettingsTest::loadsDevnetChannelIdentity()
{
const QString identity(64, QLatin1Char('a'));
QTemporaryFile config;
QVERIFY(config.open());
const QByteArray contents = QJsonDocument(QJsonObject {
{ QStringLiteral("channelId"), identity },
}).toJson(QJsonDocument::Compact);
QCOMPARE(config.write(contents), qint64(contents.size()));
config.flush();
const auto settings = SequencerNetworkSettingsLoader::load(
QStringLiteral("devnet"), config.fileName());
QVERIFY(settings);
QCOMPARE(settings->context.id, QStringLiteral("devnet"));
QCOMPARE(settings->context.expectedIdentity, identity);
QCOMPARE(settings->context.fingerprintPrefix, QStringLiteral("channel:"));
QCOMPARE(settings->identityMethod, SequencerIdentityMethod::ChannelId);
}
void SequencerNetworkSettingsTest::rejectsInvalidDevnetIdentity()
{
QTemporaryFile config;
QVERIFY(config.open());
const QByteArray contents = QJsonDocument(QJsonObject {
{ QStringLiteral("channelId"), QStringLiteral("not-an-identity") },
}).toJson(QJsonDocument::Compact);
QCOMPARE(config.write(contents), qint64(contents.size()));
config.flush();
QVERIFY(!SequencerNetworkSettingsLoader::load(
QStringLiteral("devnet"), config.fileName()));
}
QTEST_GUILESS_MAIN(SequencerNetworkSettingsTest)
#include "SequencerNetworkSettingsTest.moc"
@@ -0,0 +1,22 @@
#include "WalletIdlDecoder.h"
#include <QtTest>
class WalletIdlDecoderLinkTest final : public QObject {
Q_OBJECT
private slots:
void linksDefaultDecoder();
};
void WalletIdlDecoderLinkTest::linksDefaultDecoder()
{
const WalletDecodeResult result = WalletIdlDecoder::decode(
QByteArrayLiteral("not-json"), {});
QCOMPARE(result.status, QStringLiteral("error"));
QCOMPARE(result.error, QStringLiteral("invalid_idl"));
}
QTEST_GUILESS_MAIN(WalletIdlDecoderLinkTest)
#include "WalletIdlDecoderLinkTest.moc"
@@ -0,0 +1,206 @@
#include "WalletPortfolioService.h"
#include <QtTest>
#include <utility>
// The service normally links this symbol from wallet-idl-decoder. Every test
// injects a decoder, so keep this target independent from the Rust FFI library.
WalletDecodeResult WalletIdlDecoder::decode(const QByteArray&,
const QVector<WalletAccountRead>&)
{
return {
QStringLiteral("error"),
QStringLiteral("unexpected_default_decoder"),
{},
};
}
namespace {
const QString DEFINITION_ID(64, QLatin1Char('a'));
const QString DEFINITION_BASE58 = QStringLiteral("base58-definition");
const QString HOLDING_ID(64, QLatin1Char('b'));
const QString TOKEN_PROGRAM_ID(64, QLatin1Char('c'));
const QString AMM_ACCOUNT_ID(64, QLatin1Char('d'));
const QString AMM_PROGRAM_ID(64, QLatin1Char('e'));
WalletAccountRead read(const QString& accountId,
const QString& programOwner,
const QString& data)
{
WalletAccountRead result;
result.accountId = accountId;
result.status = QStringLiteral("ok");
result.programOwner = programOwner;
result.dataHex = data;
return result;
}
WalletPortfolioRequest request(const QString& name = QStringLiteral("Test token"))
{
WalletSnapshot snapshot;
snapshot.publicAccountReads = {
read(DEFINITION_ID, TOKEN_PROGRAM_ID, QStringLiteral("definition")),
read(HOLDING_ID, TOKEN_PROGRAM_ID, QStringLiteral("holding")),
read(AMM_ACCOUNT_ID, AMM_PROGRAM_ID, QStringLiteral("amm")),
};
WalletPortfolioRequest result(snapshot);
result.tokenDefinitionIds = { DEFINITION_BASE58 };
result.tokens = { QVariantMap {
{ QStringLiteral("definitionId"), DEFINITION_BASE58 },
{ QStringLiteral("definitionIdHex"), DEFINITION_ID },
{ QStringLiteral("name"), name },
} };
result.tokenProgramId = TOKEN_PROGRAM_ID;
result.tokenIdl = QByteArrayLiteral("token-idl");
return result;
}
WalletDecodedAccount tokenDefinition()
{
WalletDecodedAccount account;
account.id = DEFINITION_ID;
account.status = QStringLiteral("decoded");
account.typeName = QStringLiteral("TokenDefinition");
account.value = QJsonObject {
{ QStringLiteral("Fungible"), QJsonObject {
{ QStringLiteral("name"), QStringLiteral("Test token") },
} },
};
return account;
}
WalletDecodedAccount tokenHolding(bool decoded = true)
{
WalletDecodedAccount account;
account.id = HOLDING_ID;
account.status = decoded ? QStringLiteral("decoded") : QStringLiteral("error");
account.typeName = QStringLiteral("TokenHolding");
account.value = QJsonObject {
{ QStringLiteral("Fungible"), QJsonObject {
{ QStringLiteral("definition_id"), QStringLiteral("definition") },
{ QStringLiteral("balance"), QStringLiteral("25") },
} },
};
account.accountIds.insert(QStringLiteral("definition"), DEFINITION_ID);
return account;
}
WalletDecodedAccount ammAccount()
{
WalletDecodedAccount account;
account.id = AMM_ACCOUNT_ID;
account.status = QStringLiteral("decoded");
account.typeName = QStringLiteral("Pool");
account.value = QJsonObject {
{ QStringLiteral("Pool"), QJsonObject {} },
};
return account;
}
WalletPortfolioService::Decoder decoder(int* calls, bool failHolding = false)
{
return [calls, failHolding](const QByteArray&, const QVector<WalletAccountRead>& reads) {
++*calls;
WalletDecodeResult result;
result.status = QStringLiteral("ok");
for (const WalletAccountRead& item : reads) {
if (item.accountId == DEFINITION_ID)
result.accounts.append(tokenDefinition());
else if (item.accountId == HOLDING_ID)
result.accounts.append(tokenHolding(!failHolding));
else if (item.accountId == AMM_ACCOUNT_ID)
result.accounts.append(ammAccount());
}
return result;
};
}
}
class WalletPortfolioServiceTest : public QObject {
Q_OBJECT
private slots:
void acceptsBase58DefinitionsAndReusesUnchangedDecodes();
void exposesHoldingDecodeFailureWithoutZeroBalance();
void exposesUnreadPublicAccountWithoutZeroBalance();
void exposesUnresolvedDefinitions();
};
void WalletPortfolioServiceTest::acceptsBase58DefinitionsAndReusesUnchangedDecodes()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls));
service.registerProgram(AMM_PROGRAM_ID, QStringLiteral("AMM"), QByteArrayLiteral("amm-idl"));
const WalletPortfolioResult first = service.refresh(request());
QCOMPARE(first.status, QStringLiteral("ready"));
QCOMPARE(first.assets.size(), 1);
const QVariantMap asset = first.assets.first().toMap();
QCOMPARE(asset.value(QStringLiteral("definitionId")).toString(), DEFINITION_ID);
QCOMPARE(asset.value(QStringLiteral("displayDefinitionId")).toString(), DEFINITION_BASE58);
QCOMPARE(asset.value(QStringLiteral("balance")).toString(), QStringLiteral("25"));
QCOMPARE(first.presentations.size(), 3);
const int firstDecodeCalls = decodeCalls;
QVERIFY(firstDecodeCalls > 0);
const WalletPortfolioResult renamed = service.refresh(request(QStringLiteral("Renamed")));
QCOMPARE(decodeCalls, firstDecodeCalls);
QCOMPARE(renamed.status, QStringLiteral("ready"));
QCOMPARE(renamed.assets.first().toMap().value(QStringLiteral("name")).toString(),
QStringLiteral("Renamed"));
}
void WalletPortfolioServiceTest::exposesHoldingDecodeFailureWithoutZeroBalance()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls, true));
const WalletPortfolioResult result = service.refresh(request());
QCOMPARE(result.status, QStringLiteral("partial"));
QCOMPARE(result.error, QStringLiteral("holding_decode_failed"));
const QVariantMap asset = result.assets.first().toMap();
QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("unavailable"));
QVERIFY(asset.value(QStringLiteral("balance")).toString().isEmpty());
}
void WalletPortfolioServiceTest::exposesUnreadPublicAccountWithoutZeroBalance()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls));
WalletPortfolioRequest input = request();
for (WalletAccountRead& account : input.publicAccountReads) {
if (account.accountId == HOLDING_ID)
account = WalletAccountRead { HOLDING_ID };
}
const WalletPortfolioResult result = service.refresh(input);
QCOMPARE(result.status, QStringLiteral("partial"));
QCOMPARE(result.error, QStringLiteral("public_account_read_failed"));
const QVariantMap asset = result.assets.first().toMap();
QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("unavailable"));
QVERIFY(asset.value(QStringLiteral("balance")).toString().isEmpty());
}
void WalletPortfolioServiceTest::exposesUnresolvedDefinitions()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls));
WalletPortfolioRequest input = request();
input.tokens.clear();
const WalletPortfolioResult result = service.refresh(input);
QCOMPARE(result.status, QStringLiteral("error"));
QCOMPARE(result.error, QStringLiteral("definitions_unavailable"));
QCOMPARE(result.assets.size(), 1);
QCOMPARE(result.assets.first().toMap().value(QStringLiteral("status")).toString(),
QStringLiteral("unavailable"));
}
QTEST_GUILESS_MAIN(WalletPortfolioServiceTest)
#include "WalletPortfolioServiceTest.moc"
@@ -6,6 +6,8 @@
#include <QVariant>
#include <QVariantList>
#include <functional>
class LogosAPI;
class FakeExecutionZone {
@@ -48,6 +50,13 @@ public:
return openResult;
}
void openAsync(const QString& config,
const QString& storage,
std::function<void(int)> callback)
{
callback(open(config, storage));
}
QString create_new(const QString& config,
const QString& storage,
const QString& password)
@@ -66,34 +75,69 @@ public:
QString create_account_public() { return publicAccountId; }
QString create_account_private() { return privateAccountId; }
QString account_id_to_base58(const QString& accountId) const
{
return QStringLiteral("base58-") + accountId;
}
int get_last_synced_block() const { return lastSyncedBlock; }
int get_current_block_height() const { return currentBlockHeight; }
void get_last_synced_blockAsync(std::function<void(int)> callback)
{
callback(get_last_synced_block());
}
void get_current_block_heightAsync(std::function<void(int)> callback)
{
callback(get_current_block_height());
}
int sync_to_block(quint64)
{
++syncCalls;
return syncResult;
}
void sync_to_blockAsync(int blockId, std::function<void(int)> callback)
{
callback(sync_to_block(static_cast<quint64>(blockId)));
}
QString get_sequencer_addr() const { return sequencerAddress; }
void get_sequencer_addrAsync(std::function<void(QString)> callback)
{
callback(get_sequencer_addr());
}
QVariantList list_accounts()
{
++listCalls;
return accounts;
}
void list_accountsAsync(std::function<void(QVariantList)> callback)
{
callback(list_accounts());
}
QString get_account_public(const QString& accountId)
{
++publicReadCalls;
return publicAccounts.value(accountId);
}
void get_account_publicAsync(const QString& accountId,
std::function<void(QString)> callback)
{
callback(get_account_public(accountId));
}
QString get_balance(const QString& accountId, bool) const
{
return balances.value(accountId);
}
void get_balanceAsync(const QString& accountId,
bool isPublic,
std::function<void(QString)> callback)
{
callback(get_balance(accountId, isPublic));
}
QString send_generic_public_transaction(
const QStringList& accountIds,
@@ -108,6 +152,7 @@ public:
submittedProgramId = programId;
return transactionResponse;
}
};
struct LogosModules {
@@ -0,0 +1,47 @@
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 {}
}
TestCase {
name: "CopyButton"
when: windowShown
function test_copiesText() {
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)
copyButton.click()
verify(copyButton.copied)
sink.paste()
tryCompare(sink, "text", value)
}
}
}
@@ -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")
@@ -13,32 +13,65 @@ Item {
QtObject {
property bool isWalletOpen: false
property bool walletExists: true
property bool completeOpenImmediately: true
property bool createWalletFails: false
property bool createWalletRefreshFails: false
property bool accountRefreshFails: false
property string walletHome: "/wallet"
property string walletSyncStatus: "closed"
property string walletSyncError: ""
property bool deferOpen: false
property int openCalls: 0
property int createCalls: 0
property int publicAccountCalls: 0
property int privateAccountCalls: 0
property int disconnectCalls: 0
property int primaryAccountCalls: 0
property int aliasCalls: 0
property string primaryAccountAddress: ""
property string primaryAccountName: ""
property string activeNetwork: "testnet"
property string networkStatus: "ready"
property string assetStatus: "ready"
property string assetError: ""
property var assets: []
function openExisting() {
openCalls++
isWalletOpen = true
if (deferOpen) {
walletSyncStatus = "opening"
} else {
isWalletOpen = true
walletSyncStatus = "ready"
}
return true
}
function createNewDefault(_password) {
createCalls++
if (createWalletFails)
return ""
isWalletOpen = true
walletSyncStatus = createWalletRefreshFails ? "error" : "ready"
walletSyncError = createWalletRefreshFails ? "read_failed" : ""
return "alpha beta gamma"
}
function createAccountPublic() {
publicAccountCalls++
if (accountRefreshFails) {
walletSyncStatus = "error"
walletSyncError = "read_failed"
}
return "a".repeat(64)
}
function createAccountPrivate() {
privateAccountCalls++
if (accountRefreshFails) {
walletSyncStatus = "error"
walletSyncError = "read_failed"
}
return "b".repeat(64)
}
@@ -46,6 +79,17 @@ Item {
disconnectCalls++
isWalletOpen = false
}
function setPrimaryAccount(address) {
primaryAccountCalls++
primaryAccountAddress = address
return true
}
function setAccountAlias(_address, _alias) {
aliasCalls++
return true
}
}
}
@@ -54,6 +98,25 @@ Item {
ListModel { }
}
Component {
id: portfolioComponent
QtObject {
property string assetStatus: "ready"
property string assetError: ""
property var assets: []
}
}
Component {
id: networkComponent
QtObject {
property string activeNetwork: ""
property string networkStatus: "ready"
}
}
Component {
id: controlComponent
Wallet.WalletControl {
@@ -115,7 +178,7 @@ Item {
const model = createTemporaryObject(modelComponent, root)
verify(model, "Account model exists")
for (const account of accounts || [])
model.append(account)
model.append(accountData(account))
const control = createTemporaryObject(controlComponent, root, {
wallet: backend,
accountModel: model
@@ -124,6 +187,25 @@ Item {
return { backend, model, control }
}
function accountData(account) {
return {
name: account.name || "Account",
alias: account.alias || "",
address: account.address || "",
displayAddress: account.displayAddress || account.address || "",
balance: account.balance || "0",
isPublic: account.isPublic === true,
kind: account.kind || (account.isPublic === false ? "private" : "user"),
section: account.section || "accounts",
programName: account.programName || "",
accountType: account.accountType || "",
decodedData: account.decodedData || "",
visibility: account.visibility || (account.isPublic === false ? "private" : "public"),
canBePrimary: account.canBePrimary === undefined ? true : account.canBePrimary,
isPrimary: account.isPrimary === true
}
}
function test_opensExistingWallet() {
const fixture = createControl({ walletExists: true }, [])
const connectButton = findChild(fixture.control, "walletConnectButton")
@@ -133,6 +215,19 @@ Item {
tryCompare(fixture.control, "connected", true)
}
function test_surfacesDeferredOpenFailure() {
const fixture = createControl({ walletExists: true, deferOpen: true }, [])
mouseClick(findChild(fixture.control, "walletConnectButton"))
compare(fixture.backend.openCalls, 1)
compare(fixture.control.syncStatus, "opening")
fixture.backend.walletSyncStatus = "error"
fixture.backend.walletSyncError = "open_failed"
const dialog = findChild(fixture.control, "walletMessageDialog")
tryCompare(dialog, "opened", true)
verify(dialog.message.includes("open_failed"))
}
function test_requiresSeedBackupAcknowledgement() {
const fixture = createControl({ walletExists: false }, [])
mouseClick(findChild(fixture.control, "walletConnectButton"))
@@ -165,6 +260,42 @@ Item {
tryCompare(dialog, "opened", false)
}
function test_showsWalletCreationFailure() {
const fixture = createControl({ walletExists: false, createWalletFails: true }, [])
mouseClick(findChild(fixture.control, "walletConnectButton"))
const dialog = findChild(fixture.control, "createWalletDialog")
tryCompare(dialog, "opened", true)
findChild(dialog, "walletPasswordField").text = "secret"
findChild(dialog, "walletConfirmPasswordField").text = "secret"
findChild(dialog, "createWalletButton").clicked()
compare(fixture.backend.createCalls, 1)
compare(dialog.mnemonic, "")
compare(dialog.errorText, "Wallet could not be created.")
verify(dialog.opened)
}
function test_warnsWhenCreatedWalletCannotRefresh() {
const fixture = createControl({
walletExists: false,
createWalletRefreshFails: true
}, [])
mouseClick(findChild(fixture.control, "walletConnectButton"))
const dialog = findChild(fixture.control, "createWalletDialog")
tryCompare(dialog, "opened", true)
findChild(dialog, "walletPasswordField").text = "secret"
findChild(dialog, "walletConfirmPasswordField").text = "secret"
findChild(dialog, "createWalletButton").clicked()
tryCompare(dialog, "mnemonic", "alpha beta gamma")
const message = findChild(fixture.control, "walletMessageDialog")
verify(!message.opened)
mouseClick(findChild(dialog, "walletBackupAcknowledgement"))
mouseClick(findChild(dialog, "walletContinueButton"))
tryCompare(message, "opened", true)
compare(message.message,
"Wallet was created, but could not be refreshed. Reconnect the wallet to refresh it.")
}
function test_clampsSelectionAndDisconnectsLocally() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true },
@@ -173,12 +304,12 @@ Item {
fixture.control.selectedIndex = 1
compare(fixture.control.selectedAddress, "b".repeat(64))
fixture.model.clear()
tryCompare(fixture.control, "selectedIndex", 0)
tryCompare(fixture.control, "selectedIndex", -1)
compare(fixture.control.selectedAddress, "")
fixture.model.append({
fixture.model.append(accountData({
name: "One", address: "a".repeat(64), balance: "10", isPublic: true
})
}))
mouseClick(findChild(fixture.control, "walletAccountButton"))
const disconnectButton = findChild(fixture.control, "walletDisconnectButton")
tryVerify(function() { return disconnectButton.visible })
@@ -187,6 +318,31 @@ Item {
tryCompare(fixture.control, "connected", false)
}
function test_waitsForPrimaryDelegateBeforeShowingAccountType() {
const address = "a".repeat(64)
const fixture = createControl({
isWalletOpen: true,
primaryAccountAddress: address,
primaryAccountName: "Primary"
}, [])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const accountType = findChild(fixture.control, "walletPrimaryAccountType")
verify(accountType, "Primary account type exists")
verify(!accountType.visible, "Account type waits for its selected delegate")
fixture.model.append(accountData({
name: "Primary",
address: address,
balance: "10",
isPublic: true,
isPrimary: true
}))
tryCompare(fixture.control, "selectedAddress", address)
tryCompare(accountType, "visible", true)
compare(accountType.text, "Public user account")
}
function test_connectedButtonClosesOpenMenu() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
@@ -200,6 +356,53 @@ Item {
tryCompare(menu, "opened", false)
}
function test_walletMenuClosesWithEscape() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
])
const accountButton = findChild(fixture.control, "walletAccountButton")
const menu = findChild(fixture.control, "walletMenu")
mouseClick(accountButton)
tryCompare(menu, "opened", true)
keyClick(Qt.Key_Escape)
tryCompare(menu, "opened", false)
}
function test_createAccountDialogOwnsKeyboardFocus() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
mouseClick(findChild(fixture.control, "walletAccountsButton"))
const backButton = findChild(fixture.control, "walletAccountsBackButton")
const addButton = findChild(fixture.control, "walletAddAccountButton")
verify(backButton && addButton, "Account controls exist")
mouseClick(addButton)
const dialog = findChild(fixture.control, "createAccountDialog")
const privateSwitch = findChild(dialog, "privateAccountSwitch")
tryCompare(dialog, "opened", true)
tryVerify(function() { return privateSwitch.activeFocus })
for (let index = 0; index < 6; ++index) {
keyClick(Qt.Key_Tab)
verify(!backButton.activeFocus, "Focus remains inside the dialog")
}
keyClick(Qt.Key_Escape)
tryCompare(dialog, "opened", false)
}
function test_walletMessageDialogClosesWithEscape() {
const fixture = createControl({ isWalletOpen: true }, [])
const dialog = findChild(fixture.control, "walletMessageDialog")
dialog.open()
tryCompare(dialog, "opened", true)
keyClick(Qt.Key_Escape)
tryCompare(dialog, "opened", false)
}
function test_openMenuTracksControlMovement() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
@@ -226,8 +429,20 @@ Item {
function test_selectsAccount() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true },
{ name: "Two", address: "b".repeat(64), balance: "20", isPublic: false }
{
name: "One",
address: "a".repeat(64),
displayAddress: "base58-one",
balance: "10",
isPublic: true
},
{
name: "Two",
address: "b".repeat(64),
displayAddress: "base58-two",
balance: "20",
isPublic: false
}
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const accountsButton = findChild(fixture.control, "walletAccountsButton")
@@ -238,9 +453,292 @@ Item {
tryCompare(accountList, "count", 2)
tryVerify(function() { return accountList.itemAtIndex(1) !== null })
const secondAccount = accountList.itemAtIndex(1)
secondAccount.clicked()
mouseClick(secondAccount)
tryCompare(fixture.control, "selectedIndex", 1)
compare(fixture.backend.primaryAccountAddress, "b".repeat(64))
compare(fixture.control.selectedAddress, "b".repeat(64))
compare(fixture.control.selectedDisplayAddress, "base58-two")
}
function test_flatWalletActionsUseReadableForeground() {
const fixture = createControl({
isWalletOpen: true,
assets: [{
name: "Available",
balance: "0",
definitionId: "c".repeat(64),
displayDefinitionId: "base58-available",
status: "ready",
section: "available"
}]
}, [
{
name: "One",
address: "a".repeat(64),
balance: "10",
isPublic: true,
isPrimary: true
},
{
name: "Two",
address: "b".repeat(64),
balance: "20",
isPublic: true
}
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const available = findChild(fixture.control, "walletAvailableAssetsButton")
tryVerify(function() { return available && available.visible })
compare(available.flat, true)
compare(available.palette.windowText, "#d4d4d8")
compare(available.contentItem.color, "#d4d4d8")
mouseClick(available)
tryCompare(fixture.control, "availableExpanded", true)
mouseClick(findChild(fixture.control, "walletAccountsButton"))
const advanced = findChild(fixture.control, "walletAdvancedAccountsButton")
tryVerify(function() { return advanced && advanced.visible })
compare(advanced.flat, true)
compare(advanced.palette.windowText, "#d4d4d8")
compare(advanced.contentItem.color, "#d4d4d8")
const accountList = findChild(fixture.control, "walletAccountList")
tryVerify(function() { return accountList.itemAtIndex(1) !== null })
const secondAccount = accountList.itemAtIndex(1)
const rename = findChild(secondAccount, "walletRenameButton")
const makePrimary = findChild(secondAccount, "walletMakePrimaryButton")
verify(rename && makePrimary, "Account action buttons exist")
for (const action of [rename, makePrimary]) {
compare(action.flat, true)
compare(action.palette.windowText, "#d4d4d8")
compare(action.contentItem.color, "#d4d4d8")
}
}
function test_tokenAssetsRenderInBoxes() {
const fixture = createControl({
isWalletOpen: true,
assets: [
{
name: "Held token",
balance: "42",
definitionId: "c".repeat(64),
displayDefinitionId: "base58-held-token",
status: "ready",
section: "assets"
},
{
name: "Available token",
balance: "0",
definitionId: "d".repeat(64),
displayDefinitionId: "base58-available-token",
status: "ready",
section: "available"
}
]
}, [])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const heldRepeater = findChild(fixture.control, "walletAssetRepeater")
verify(heldRepeater, "Held token repeater exists")
let held = null
tryVerify(function() {
held = heldRepeater.itemAt(0)
return held !== null
})
verify(held, "Held token box exists")
tryCompare(held, "visible", true)
compare(held.implicitHeight, 68)
compare(held.radius, 10)
compare(held.border.width, 1)
compare(held.border.color, "#3f3f46")
const availableRepeater = findChild(fixture.control, "walletAvailableAssetRepeater")
verify(availableRepeater, "Available token repeater exists")
let available = null
tryVerify(function() {
available = availableRepeater.itemAt(1)
return available !== null
})
verify(available, "Available token box exists")
compare(available.visible, false)
mouseClick(findChild(fixture.control, "walletAvailableAssetsButton"))
tryCompare(available, "visible", true)
compare(available.implicitHeight, 64)
compare(available.radius, 10)
compare(available.border.width, 1)
compare(available.border.color, "#3f3f46")
}
function test_usesExplicitPortfolioAndNetworkProviders() {
const fixture = createControl({
isWalletOpen: true,
activeNetwork: "wallet network",
networkStatus: "error",
assetStatus: "ready",
assets: [{
name: "Wallet available token",
balance: "0",
definitionId: "a".repeat(64),
status: "ready",
section: "available"
}]
}, [])
const portfolio = createTemporaryObject(portfolioComponent, root, {
assetStatus: "loading",
assets: [{
name: "Portfolio token",
balance: "42",
definitionId: "b".repeat(64),
status: "ready",
section: "assets"
}]
})
const network = createTemporaryObject(networkComponent, root, {
activeNetwork: "shared testnet",
networkStatus: "loading"
})
verify(portfolio && network, "Shared providers exist")
fixture.control.portfolio = portfolio
fixture.control.network = network
compare(fixture.control.portfolioProvider, portfolio)
compare(fixture.control.networkProvider, network)
compare(fixture.control.walletAssets[0].name, "Portfolio token")
compare(fixture.control.assetStatus, "loading")
compare(fixture.control.activeNetwork, "shared testnet")
compare(fixture.control.networkStatus, "loading")
mouseClick(findChild(fixture.control, "walletAccountButton"))
const indicator = findChild(fixture.control, "walletNetworkStatusIndicator")
const networkName = findChild(fixture.control, "walletNetworkName")
const loading = findChild(fixture.control, "walletAssetsLoadingLabel")
const heldAssets = findChild(fixture.control, "walletAssetRepeater")
verify(indicator && networkName && loading && heldAssets, "Provider UI exists")
tryCompare(indicator, "color", "#f59e0b")
tryCompare(networkName, "text", "shared testnet")
tryCompare(loading, "visible", true)
tryCompare(heldAssets, "count", 1)
tryCompare(heldAssets.itemAt(0), "visible", true)
}
function test_accountNavigationKeepsOverviewInsidePopup() {
const assets = []
for (let index = 0; index < 10; ++index) {
assets.push({
name: "Token " + index,
balance: "100",
definitionId: "c".repeat(64),
displayDefinitionId: "base58-token-" + index,
status: "ready",
section: "assets"
})
}
const fixture = createControl({ isWalletOpen: true, assets: assets }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const stack = findChild(fixture.control, "walletStack")
verify(stack, "Wallet stack exists")
verify(stack.clip, "Wallet pages are clipped to the popup")
mouseClick(findChild(fixture.control, "walletAccountsButton"))
tryCompare(stack, "busy", false)
compare(stack.depth, 2)
mouseClick(findChild(fixture.control, "walletAccountsBackButton"))
tryCompare(stack, "busy", false)
compare(stack.depth, 1)
compare(stack.currentItem.x, 0)
const overviewContent = findChild(fixture.control, "walletOverviewContent")
verify(overviewContent, "Wallet overview content exists")
compare(overviewContent.mapToItem(stack, 0, 0).x, 0)
}
function test_programRecordCannotBecomePrimary() {
const userAddress = "a".repeat(64)
const programAddress = "c".repeat(64)
const fixture = createControl({
isWalletOpen: true,
primaryAccountAddress: userAddress,
primaryAccountName: "Trading"
}, [
{
name: "Trading",
address: userAddress,
balance: "10",
isPublic: true,
kind: "user",
isPrimary: true
},
{
name: "Token definition",
address: programAddress,
balance: "0",
isPublic: true,
kind: "token_definition",
section: "advanced",
programName: "Token",
accountType: "TokenDefinition",
canBePrimary: false
}
])
compare(fixture.control.selectedAddress, userAddress)
mouseClick(findChild(fixture.control, "walletAccountButton"))
mouseClick(findChild(fixture.control, "walletAccountsButton"))
mouseClick(findChild(fixture.control, "walletAdvancedAccountsButton"))
const list = findChild(fixture.control, "walletAccountList")
tryVerify(function() { return list.itemAtIndex(1) !== null })
mouseClick(list.itemAtIndex(1))
compare(fixture.backend.primaryAccountCalls, 0)
compare(fixture.control.selectedAddress, userAddress)
}
function test_advancedShowsProgramAndDecodedData() {
const decodedData = "{\n \"name\": \"Test token\"\n}"
const fixture = createControl({ isWalletOpen: true }, [
{
name: "Token definition",
address: "c".repeat(64),
balance: "0",
isPublic: true,
kind: "token_definition",
section: "advanced",
programName: "Token",
accountType: "TokenDefinition",
decodedData: decodedData,
canBePrimary: false
}
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
mouseClick(findChild(fixture.control, "walletAccountsButton"))
mouseClick(findChild(fixture.control, "walletAdvancedAccountsButton"))
const list = findChild(fixture.control, "walletAccountList")
tryVerify(function() { return list.itemAtIndex(0) !== null })
const program = findChild(list.itemAtIndex(0), "walletProgramName")
const decoded = findChild(list.itemAtIndex(0), "walletDecodedData")
verify(program && decoded, "Advanced details exist")
tryCompare(program, "visible", true)
compare(program.text, "Program: Token")
tryCompare(decoded, "visible", true)
compare(decoded.text, decodedData)
}
function test_onlyProgramRecordsLeavesPrimaryEmpty() {
const fixture = createControl({ isWalletOpen: true }, [{
name: "Token definition",
address: "c".repeat(64),
balance: "0",
isPublic: true,
kind: "token_definition",
section: "advanced",
canBePrimary: false
}])
compare(fixture.control.selectedIndex, -1)
compare(fixture.control.selectedAddress, "")
compare(fixture.control.primaryName, "")
}
function test_createsAccount() {
@@ -262,6 +760,47 @@ Item {
tryCompare(dialog, "opened", false)
}
function test_createsPrivateAccount() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
mouseClick(findChild(fixture.control, "walletAccountsButton"))
const addButton = findChild(fixture.control, "walletAddAccountButton")
tryVerify(function() { return addButton.visible })
addButton.clicked()
const dialog = findChild(fixture.control, "createAccountDialog")
tryCompare(dialog, "opened", true)
mouseClick(findChild(dialog, "privateAccountSwitch"))
findChild(dialog, "createAccountButton").clicked()
compare(fixture.backend.privateAccountCalls, 1)
tryCompare(dialog, "opened", false)
}
function test_warnsWhenCreatedAccountCannotRefresh() {
const fixture = createControl({
isWalletOpen: true,
walletSyncStatus: "ready",
accountRefreshFails: true
}, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
mouseClick(findChild(fixture.control, "walletAccountsButton"))
const addButton = findChild(fixture.control, "walletAddAccountButton")
tryVerify(function() { return addButton.visible })
addButton.clicked()
const dialog = findChild(fixture.control, "createAccountDialog")
tryCompare(dialog, "opened", true)
findChild(dialog, "createAccountButton").clicked()
tryCompare(dialog, "opened", false)
const message = findChild(fixture.control, "walletMessageDialog")
tryCompare(message, "opened", true)
compare(message.message,
"Account was created, but could not be refreshed. Reconnect the wallet to refresh it.")
}
function test_compactLayoutHasStableWidth() {
const fixture = createControl({ isWalletOpen: false }, [])
fixture.control.viewportWidth = 480
@@ -300,12 +839,12 @@ Item {
const model = createTemporaryObject(modelComponent, root)
verify(backend && model, "Wallet fixture exists")
for (let index = 0; index < 10; ++index) {
model.append({
model.append(accountData({
name: "Account " + index,
address: String(index).repeat(64),
balance: String(index),
isPublic: true
})
}))
}
const window = createTemporaryObject(compactWindowComponent, root)
@@ -1,5 +1,7 @@
#pragma once
#include <utility>
#include "WalletProvider.h"
class FakeWalletProvider final : public WalletProvider {
@@ -23,6 +25,9 @@ public:
bool lastAccountWasPublic = false;
WalletPaths lastPaths;
WalletTransaction lastTransaction;
bool deferAsync = false;
SessionCallback pendingConnectCallback;
SnapshotCallback pendingSnapshotCallback;
WalletSession connect(const WalletPaths& paths) override
{
@@ -31,6 +36,16 @@ public:
return connectResult;
}
void connectAsync(const WalletPaths& paths, SessionCallback callback) override
{
++connectCalls;
lastPaths = paths;
if (deferAsync)
pendingConnectCallback = std::move(callback);
else
callback(connectResult);
}
WalletCreation createWallet(const WalletPaths& paths,
const QString&) override
{
@@ -46,6 +61,16 @@ public:
return snapshotResult;
}
void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override
{
++snapshotCalls;
lastForceRefresh = forceRefresh;
if (deferAsync)
pendingSnapshotCallback = std::move(callback);
else
callback(snapshotResult);
}
void clearSnapshot() override { ++clearCalls; }
WalletAccountCreation createAccount(bool isPublic) override
@@ -72,4 +97,19 @@ public:
}
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);
}
};