feat(wallet): add reusable wallet modules

Add program-neutral wallet access, a stable account model, reusable QML controls, transaction confirmation, submitted-transaction presentation, and isolated contract tests.\n\nRefs #227
This commit is contained in:
Ricardo Guilherme Schmidt 2026-07-15 11:55:39 -03:00
parent 5b82a52c6b
commit fe979400af
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
28 changed files with 3110 additions and 0 deletions

View File

@ -0,0 +1,154 @@
cmake_minimum_required(VERSION 3.21)
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
project(LogosWallet LANGUAGES CXX)
include(CTest)
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")
if(LOGOS_WALLET_BUILD_ACCESS
AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/logos_sdk.h"
AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/include/logos_sdk.h")
message(FATAL_ERROR
"logos_wallet_access requires logos_sdk.h; set LOGOS_WALLET_GENERATED_DIR"
)
endif()
find_package(Qt6 6.8 REQUIRED COMPONENTS Core)
set(CMAKE_AUTOMOC ON)
if(LOGOS_WALLET_BUILD_ACCESS)
add_library(logos_wallet_access STATIC
src/WalletProvider.h
src/WalletProvider.cpp
src/LogosWalletProvider.h
src/LogosWalletProvider.cpp
src/WalletAccountModel.h
src/WalletAccountModel.cpp
)
set_target_properties(logos_wallet_access PROPERTIES
AUTOMOC ON
POSITION_INDEPENDENT_CODE ON
)
target_compile_features(logos_wallet_access PUBLIC cxx_std_17)
target_include_directories(logos_wallet_access
PUBLIC
"$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/src>"
PRIVATE
"${LOGOS_WALLET_GENERATED_DIR}"
"${LOGOS_WALLET_GENERATED_DIR}/include"
)
target_link_libraries(logos_wallet_access PUBLIC Qt6::Core)
endif()
if(LOGOS_WALLET_BUILD_QML)
find_package(Qt6 6.8 REQUIRED COMPONENTS Qml Quick QuickControls2)
qt_policy(SET QTP0004 NEW)
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/WalletControl.qml
qml/TransactionConfirmationDialog.qml
qml/SubmittedTransaction.qml
)
set(wallet_icons
qml/internal/icons/account.svg
qml/internal/icons/back.svg
qml/internal/icons/checkmark.svg
qml/internal/icons/copy.svg
qml/internal/icons/power.svg
)
foreach(qml_file IN LISTS wallet_public_qml wallet_internal_qml)
get_filename_component(qml_name "${qml_file}" NAME)
set_source_files_properties("${qml_file}" PROPERTIES QT_RESOURCE_ALIAS "${qml_name}")
endforeach()
foreach(icon IN LISTS wallet_icons)
get_filename_component(icon_name "${icon}" NAME)
set_source_files_properties("${icon}" PROPERTIES QT_RESOURCE_ALIAS "icons/${icon_name}")
endforeach()
set_source_files_properties(${wallet_internal_qml} PROPERTIES QT_QML_INTERNAL_TYPE TRUE)
qt_add_library(logos_wallet_qml SHARED)
qt_add_qml_module(logos_wallet_qml
URI Logos.Wallet
VERSION 1.0
RESOURCE_PREFIX /qt/qml
OUTPUT_DIRECTORY "${wallet_qml_output_dir}"
TYPEINFO plugins.qmltypes
QML_FILES
${wallet_public_qml}
${wallet_internal_qml}
RESOURCES
${wallet_icons}
)
target_link_libraries(logos_wallet_qml PRIVATE
Qt6::Core
Qt6::Qml
Qt6::Quick
Qt6::QuickControls2
)
set_target_properties(logos_wallet_qml logos_wallet_qmlplugin PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${wallet_qml_output_dir}"
RUNTIME_OUTPUT_DIRECTORY "${wallet_qml_output_dir}"
BUILD_WITH_INSTALL_RPATH ON
INSTALL_RPATH "$ORIGIN"
)
set(wallet_qml_install_dir "lib/qml/Logos/Wallet")
install(TARGETS logos_wallet_qml logos_wallet_qmlplugin
LIBRARY DESTINATION "${wallet_qml_install_dir}"
RUNTIME DESTINATION "${wallet_qml_install_dir}"
)
install(FILES
"${wallet_qml_output_dir}/qmldir"
"${wallet_qml_output_dir}/plugins.qmltypes"
DESTINATION "${wallet_qml_install_dir}"
OPTIONAL
)
endif()
if(BUILD_TESTING)
find_package(Qt6 6.8 REQUIRED COMPONENTS Test)
add_executable(logos_wallet_access_test
tests/cpp/LogosWalletProviderTest.cpp
src/WalletProvider.cpp
src/LogosWalletProvider.cpp
src/WalletAccountModel.cpp
src/WalletAccountModel.h
)
set_target_properties(logos_wallet_access_test PROPERTIES AUTOMOC ON)
target_compile_features(logos_wallet_access_test PRIVATE cxx_std_17)
target_include_directories(logos_wallet_access_test PRIVATE
tests/cpp/fixtures
tests/support
src
)
target_link_libraries(logos_wallet_access_test PRIVATE Qt6::Core Qt6::Test)
add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test)
if(LOGOS_WALLET_BUILD_QML)
find_package(Qt6 6.8 REQUIRED COMPONENTS QuickTest)
add_executable(logos_wallet_qml_test tests/qml/main.cpp)
target_compile_definitions(logos_wallet_qml_test PRIVATE
QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/qml"
)
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)
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"
)
endif()
endif()

View File

@ -0,0 +1,86 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
Item {
id: root
property string title: qsTr("Transaction submitted")
property string transactionId: ""
readonly property bool base58Shape: root.transactionId.length > 0
&& /^[1-9A-HJ-NP-Za-km-z]+$/.test(root.transactionId)
implicitWidth: 420
implicitHeight: resultCard.implicitHeight
TextEdit {
id: clipboardProxy
visible: false
}
function copyTransactionId() {
if (!root.transactionId)
return
clipboardProxy.text = root.transactionId
clipboardProxy.selectAll()
clipboardProxy.copy()
clipboardProxy.deselect()
clipboardProxy.text = ""
}
Rectangle {
id: resultCard
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: resultContent.implicitHeight + 32
color: "#18181b"
border.color: "#3f3f46"
border.width: 1
radius: 8
ColumnLayout {
id: resultContent
anchors.fill: parent
anchors.margins: 16
spacing: 12
Label {
Layout.fillWidth: true
text: root.title
color: "#f4f4f5"
font.bold: true
font.pixelSize: 17
wrapMode: Text.WordWrap
}
Label {
Layout.fillWidth: true
text: qsTr("Transaction ID")
color: "#a1a1aa"
font.pixelSize: 12
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
id: transactionLabel
objectName: "submittedTransactionId"
Layout.fillWidth: true
text: root.transactionId
color: "#f4f4f5"
font.family: "monospace"
wrapMode: Text.WrapAnywhere
Accessible.name: qsTr("Transaction ID %1").arg(root.transactionId)
}
CopyButton {
objectName: "copySubmittedTransactionButton"
enabled: root.transactionId.length > 0
onCopyRequested: root.copyTransactionId()
}
}
}
}
}

View File

@ -0,0 +1,169 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
Popup {
id: root
property string title: qsTr("Confirm transaction")
property string cancelText: qsTr("Cancel")
property string confirmText: qsTr("Confirm")
property bool busy: false
property var snapshot: ({})
property Component summary: null
property bool confirmationPending: false
signal canceled
signal confirmed(var snapshot)
modal: true
dim: true
padding: 20
width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420))
height: Math.max(0, Math.min(implicitHeight, parent ? parent.height - 32 : implicitHeight))
x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0
y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0
closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside
focus: true
function cloneSnapshot(value) {
if (value === undefined || value === null)
return ({})
try {
return JSON.parse(JSON.stringify(value))
} catch (_error) {
return value
}
}
function openWithSnapshot(nextSnapshot) {
root.snapshot = root.cloneSnapshot(nextSnapshot)
root.confirmationPending = false
root.open()
cancelButton.forceActiveFocus()
}
function cancel() {
if (root.busy)
return
root.confirmationPending = false
root.close()
root.canceled()
}
function confirm() {
if (root.busy)
return
root.confirmationPending = true
root.confirmed(root.snapshot)
if (!root.busy) {
root.confirmationPending = false
root.close()
}
}
onBusyChanged: {
if (!root.busy && root.confirmationPending) {
root.confirmationPending = false
root.close()
}
}
onSnapshotChanged: {
if (summaryLoader.item && summaryLoader.item.hasOwnProperty("snapshot"))
summaryLoader.item.snapshot = root.snapshot
}
Overlay.modal: Rectangle { color: "#99000000" }
background: Rectangle {
color: "#18181b"
border.color: "#3f3f46"
border.width: 1
radius: 8
}
contentItem: ColumnLayout {
spacing: 16
Label {
Layout.fillWidth: true
text: root.title
color: "#f4f4f5"
font.bold: true
font.pixelSize: 17
wrapMode: Text.WordWrap
}
ScrollView {
id: summaryScroll
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 0
Layout.preferredHeight: summaryLoader.implicitHeight
clip: true
contentWidth: availableWidth
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
Loader {
id: summaryLoader
objectName: "transactionSummaryLoader"
width: summaryScroll.availableWidth
sourceComponent: root.summary
onLoaded: {
if (item && item.hasOwnProperty("snapshot"))
item.snapshot = root.snapshot
}
}
}
BusyIndicator {
Layout.alignment: Qt.AlignHCenter
visible: root.busy
running: root.busy
Accessible.name: qsTr("Submitting transaction")
}
RowLayout {
Layout.fillWidth: true
spacing: 10
Button {
id: cancelButton
objectName: "transactionCancelButton"
Layout.fillWidth: true
implicitHeight: 44
text: root.cancelText
enabled: !root.busy
Accessible.name: text
onClicked: root.cancel()
}
Button {
id: confirmButton
objectName: "transactionConfirmButton"
Layout.fillWidth: true
implicitHeight: 44
text: root.busy ? qsTr("Submitting...") : root.confirmText
enabled: !root.busy
Accessible.name: text
onClicked: root.confirm()
background: Rectangle {
color: confirmButton.enabled
? confirmButton.pressed ? "#d95c1e" : "#f26a21"
: "#52525b"
radius: 6
}
contentItem: Label {
text: confirmButton.text
color: "#ffffff"
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}

View File

@ -0,0 +1,503 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
Item {
id: root
property var wallet: null
property var accountModel: null
property var watchCall: null
property bool compact: false
property real viewportWidth: width
property int selectedIndex: 0
property bool busy: false
readonly property bool connected: root.wallet !== null && root.wallet.isWalletOpen
readonly property bool compactLayout: root.compact || root.viewportWidth < 680
readonly property string selectedAddress: root.accountAt(root.selectedIndex, "address")
readonly property string selectedName: root.accountAt(root.selectedIndex, "name")
readonly property string selectedBalance: root.accountAt(root.selectedIndex, "balance")
readonly property bool selectedIsPublic: root.accountAt(root.selectedIndex, "isPublic") === true
implicitWidth: root.connected ? connectedButton.implicitWidth : connectButton.implicitWidth
implicitHeight: 40
Instantiator {
id: accounts
model: root.accountModel
delegate: QtObject {
required property string address
required property string name
required property string balance
required property bool isPublic
}
onCountChanged: root.clampSelection()
}
function accountAt(index, field) {
const entry = index >= 0 && index < accounts.count ? accounts.objectAt(index) : null
return entry ? entry[field] : (field === "isPublic" ? false : "")
}
function clampSelection() {
if (accounts.count === 0) {
root.selectedIndex = 0
} else {
root.selectedIndex = Math.max(0, Math.min(root.selectedIndex, accounts.count - 1))
}
}
function shortAddress(address) {
return address && address.length > 13
? address.substring(0, 6) + "..." + address.substring(address.length - 4)
: address || ""
}
function watchResult(result, success, failure) {
if (root.watchCall) {
root.watchCall(result, success, failure)
} else {
success(result)
}
}
function showError(message) {
messageDialog.message = message
messageDialog.open()
}
function openWallet() {
if (!root.wallet || root.busy)
return
root.busy = true
try {
root.watchResult(root.wallet.openExisting(), function(ok) {
root.busy = false
if (!ok)
root.showError(qsTr("Wallet could not be opened."))
}, function(error) {
root.busy = false
root.showError(qsTr("Wallet could not be opened: %1").arg(error))
})
} catch (error) {
root.busy = false
root.showError(qsTr("Wallet could not be opened: %1").arg(error))
}
}
TextEdit {
id: clipboardProxy
visible: false
}
function copyToClipboard(text) {
if (!text)
return
clipboardProxy.text = text
clipboardProxy.selectAll()
clipboardProxy.copy()
clipboardProxy.deselect()
clipboardProxy.text = ""
}
Connections {
target: root.accountModel
ignoreUnknownSignals: true
function onModelReset() { root.clampSelection() }
function onRowsInserted() { root.clampSelection() }
function onRowsRemoved() { root.clampSelection() }
}
onConnectedChanged: {
if (!root.connected) {
root.selectedIndex = 0
walletMenu.close()
}
}
onViewportWidthChanged: {
if (walletMenu.opened)
Qt.callLater(walletMenu.updateAnchor)
}
Button {
id: connectButton
objectName: "walletConnectButton"
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !root.connected
enabled: root.wallet !== null && !root.busy
implicitHeight: 40
implicitWidth: root.compactLayout ? 40 : 108
text: root.compactLayout ? "" : root.busy ? qsTr("Connecting...") : qsTr("Connect")
display: root.compactLayout ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon
icon.source: Qt.resolvedUrl("icons/account.svg")
icon.color: "#ffffff"
icon.width: 18
icon.height: 18
Accessible.name: qsTr("Connect wallet")
ToolTip.text: Accessible.name
ToolTip.visible: hovered && root.compactLayout
background: Rectangle {
color: connectButton.pressed ? "#d95c1e" : "#f26a21"
radius: 6
}
contentItem: RowLayout {
spacing: 6
Image {
visible: root.compactLayout
source: connectButton.icon.source
sourceSize.width: 18
sourceSize.height: 18
}
Label {
Layout.fillWidth: true
visible: !root.compactLayout
text: connectButton.text
color: "#ffffff"
font.bold: true
horizontalAlignment: Text.AlignHCenter
}
}
onClicked: {
if (root.wallet && root.wallet.walletExists)
root.openWallet()
else
createWalletDialog.open()
}
}
Button {
id: connectedButton
objectName: "walletAccountButton"
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: root.connected
enabled: !root.busy
implicitHeight: 40
implicitWidth: root.compactLayout ? 44 : Math.max(140, accountButtonLabel.implicitWidth + 58)
Accessible.name: qsTr("Wallet account %1").arg(root.selectedAddress)
background: Rectangle {
color: connectedButton.pressed ? "#3f3f46" : "#27272a"
border.width: walletMenu.opened || connectedButton.activeFocus ? 1 : 0
border.color: "#f26a21"
radius: 6
}
contentItem: RowLayout {
spacing: 8
Rectangle {
Layout.preferredWidth: 8
Layout.preferredHeight: 8
radius: 4
color: "#22c55e"
}
Label {
id: accountButtonLabel
Layout.fillWidth: true
visible: !root.compactLayout
text: root.shortAddress(root.selectedAddress) || qsTr("Connected")
color: "#f4f4f5"
horizontalAlignment: Text.AlignHCenter
}
Label {
visible: !root.compactLayout
text: walletMenu.opened ? "\u25b4" : "\u25be"
color: "#a1a1aa"
}
}
onClicked: {
if (walletMenu.opened || Date.now() - walletMenu.lastClosedMs < 200)
walletMenu.close()
else
walletMenu.open()
}
}
Popup {
id: walletMenu
objectName: "walletMenu"
property real lastClosedMs: 0
property point anchorPosition: Qt.point(0, 0)
readonly property var viewport: Overlay.overlay
readonly property real spaceAbove: Math.max(0, anchorPosition.y - 20)
readonly property real spaceBelow: viewport
? Math.max(0, viewport.height - anchorPosition.y - connectedButton.height - 20)
: implicitHeight
readonly property bool opensAbove: spaceBelow < implicitHeight && spaceAbove > spaceBelow
readonly property real availableMenuHeight: opensAbove ? spaceAbove : spaceBelow
parent: connectedButton
x: viewport
? Math.max(12 - anchorPosition.x,
Math.min(connectedButton.width - width,
viewport.width - width - 12 - anchorPosition.x))
: connectedButton.width - width
y: opensAbove
? -height - 8
: connectedButton.height + 8
width: Math.min(360, Math.max(0, Math.min(root.viewportWidth,
viewport ? viewport.width : root.viewportWidth) - 24))
height: Math.min(implicitHeight, availableMenuHeight)
margins: 12
padding: 12
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
function updateAnchor() {
if (viewport)
anchorPosition = connectedButton.mapToItem(viewport, 0, 0)
}
onAboutToShow: updateAnchor()
onClosed: {
walletMenu.lastClosedMs = Date.now()
if (walletStack.depth > 1)
walletStack.pop(null, StackView.Immediate)
}
Connections {
target: walletMenu.opened ? walletMenu.viewport : null
function onWidthChanged() { Qt.callLater(walletMenu.updateAnchor) }
function onHeightChanged() { Qt.callLater(walletMenu.updateAnchor) }
}
background: Rectangle {
color: "#18181b"
border.color: "#3f3f46"
border.width: 1
radius: 8
}
contentItem: StackView {
id: walletStack
width: walletMenu.availableWidth
height: walletMenu.availableHeight
implicitWidth: walletMenu.availableWidth
implicitHeight: currentItem ? currentItem.implicitHeight : 0
initialItem: walletOverview
}
Component {
id: walletOverview
ColumnLayout {
spacing: 12
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
WalletIconButton {
objectName: "walletAccountsButton"
iconSource: Qt.resolvedUrl("icons/account.svg")
accessibleName: qsTr("Accounts")
onClicked: walletStack.push(accountList)
}
WalletIconButton {
objectName: "walletDisconnectButton"
iconSource: Qt.resolvedUrl("icons/power.svg")
accessibleName: qsTr("Disconnect")
onClicked: {
walletMenu.close()
if (root.wallet)
root.wallet.disconnectWallet()
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: accountCard.implicitHeight + 24
color: "#27272a"
radius: 6
ColumnLayout {
id: accountCard
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: root.selectedName || qsTr("Account")
color: "#f4f4f5"
font.bold: true
}
Label {
text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private")
color: "#a1a1aa"
font.pixelSize: 11
}
Item { Layout.fillWidth: true }
Label {
text: root.selectedBalance || "-"
color: "#f4f4f5"
font.bold: true
}
}
RowLayout {
Layout.fillWidth: true
spacing: 4
Label {
Layout.fillWidth: true
text: root.selectedAddress
color: "#a1a1aa"
font.family: "monospace"
font.pixelSize: 11
elide: Text.ElideMiddle
}
CopyButton {
visible: root.selectedAddress.length > 0
onCopyRequested: root.copyToClipboard(root.selectedAddress)
}
}
}
}
}
}
Component {
id: accountList
ColumnLayout {
spacing: 12
RowLayout {
Layout.fillWidth: true
WalletIconButton {
iconSource: Qt.resolvedUrl("icons/back.svg")
accessibleName: qsTr("Back")
onClicked: walletStack.pop()
}
Label {
Layout.fillWidth: true
text: qsTr("Accounts")
color: "#f4f4f5"
font.bold: true
}
}
ListView {
id: accountListView
objectName: "walletAccountList"
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 0
Layout.preferredHeight: Math.min(contentHeight, 260)
clip: true
spacing: 6
model: root.accountModel
ScrollIndicator.vertical: ScrollIndicator { }
delegate: AccountDelegate {
width: ListView.view.width
highlighted: index === root.selectedIndex
onClicked: {
root.selectedIndex = index
walletStack.pop()
}
onCopyRequested: function(text) { root.copyToClipboard(text) }
}
}
Button {
objectName: "walletAddAccountButton"
Layout.fillWidth: true
text: qsTr("Add account")
enabled: !root.busy
onClicked: createAccountDialog.open()
}
}
}
}
CreateWalletDialog {
id: createWalletDialog
objectName: "createWalletDialog"
walletHome: root.wallet ? root.wallet.walletHome || "" : ""
busy: root.busy
onCreateRequested: function(password) {
if (!root.wallet || root.busy)
return
root.busy = true
try {
root.watchResult(root.wallet.createNewDefault(password), function(mnemonic) {
root.busy = false
if (mnemonic && mnemonic.length > 0)
createWalletDialog.mnemonic = mnemonic
else
createWalletDialog.errorText = qsTr("Wallet could not be created.")
}, function(error) {
root.busy = false
createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error)
})
} catch (error) {
root.busy = false
createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error)
}
}
onCopyRequested: function(text) { root.copyToClipboard(text) }
}
CreateAccountDialog {
id: createAccountDialog
objectName: "createAccountDialog"
busy: root.busy
onCreateRequested: function(isPublic) {
if (!root.wallet || root.busy)
return
root.busy = true
try {
const request = isPublic
? root.wallet.createAccountPublic()
: root.wallet.createAccountPrivate()
root.watchResult(request, function(accountId) {
root.busy = false
if (accountId && accountId.length > 0) {
createAccountDialog.close()
} else {
root.showError(qsTr("Account could not be created."))
}
}, function(error) {
root.busy = false
root.showError(qsTr("Account could not be created: %1").arg(error))
})
} catch (error) {
root.busy = false
root.showError(qsTr("Account could not be created: %1").arg(error))
}
}
}
WalletMessageDialog {
id: messageDialog
objectName: "walletMessageDialog"
}
}

View File

@ -0,0 +1,77 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
ItemDelegate {
id: root
required property int index
required property string name
required property string address
required property string balance
required property bool isPublic
signal copyRequested(string text)
leftPadding: 12
rightPadding: 8
topPadding: 10
bottomPadding: 10
Accessible.name: qsTr("%1, balance %2").arg(root.name).arg(root.balance || "0")
background: Rectangle {
color: root.highlighted || root.hovered ? "#27272a" : "#18181b"
radius: 6
border.width: root.activeFocus ? 1 : 0
border.color: "#f26a21"
}
contentItem: ColumnLayout {
spacing: 6
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.name
color: "#f4f4f5"
font.bold: true
}
Label {
text: root.isPublic ? qsTr("Public") : qsTr("Private")
color: "#a1a1aa"
font.pixelSize: 11
}
Item { Layout.fillWidth: true }
Label {
text: root.balance.length > 0 ? root.balance : "-"
color: "#f4f4f5"
font.bold: true
}
}
RowLayout {
Layout.fillWidth: true
spacing: 4
Label {
Layout.fillWidth: true
text: root.address
color: "#a1a1aa"
font.family: "monospace"
font.pixelSize: 11
elide: Text.ElideMiddle
}
CopyButton {
visible: root.address.length > 0
onCopyRequested: root.copyRequested(root.address)
}
}
}
}

View File

@ -0,0 +1,26 @@
import QtQuick
WalletIconButton {
id: root
signal copyRequested
property bool copied: false
accessibleName: root.copied ? qsTr("Copied") : qsTr("Copy")
iconSource: root.copied
? Qt.resolvedUrl("icons/checkmark.svg")
: Qt.resolvedUrl("icons/copy.svg")
Timer {
id: resetTimer
interval: 1500
onTriggered: root.copied = false
}
onClicked: {
root.copyRequested()
root.copied = true
resetTimer.restart()
}
}

View File

@ -0,0 +1,94 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
Popup {
id: root
property bool busy: false
signal createRequested(bool isPublic)
modal: true
dim: true
parent: Overlay.overlay
width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380))
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
closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside
onOpened: privateSwitch.checked = false
background: Rectangle {
color: "#18181b"
border.color: "#3f3f46"
border.width: 1
radius: 8
}
contentItem: ColumnLayout {
spacing: 16
Label {
Layout.fillWidth: true
text: qsTr("Create account")
color: "#f4f4f5"
font.bold: true
font.pixelSize: 17
}
RowLayout {
Layout.fillWidth: true
spacing: 12
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: privateSwitch.checked ? qsTr("Private") : qsTr("Public")
color: "#f4f4f5"
font.bold: true
}
Label {
Layout.fillWidth: true
text: privateSwitch.checked
? qsTr("Private balance and activity")
: qsTr("Public balance and activity")
color: "#a1a1aa"
wrapMode: Text.WordWrap
}
}
Switch {
id: privateSwitch
objectName: "privateAccountSwitch"
Accessible.name: qsTr("Create private account")
enabled: !root.busy
}
}
RowLayout {
Layout.fillWidth: true
spacing: 10
Button {
Layout.fillWidth: true
text: qsTr("Cancel")
enabled: !root.busy
onClicked: root.close()
}
Button {
id: createButton
objectName: "createAccountButton"
Layout.fillWidth: true
text: root.busy ? qsTr("Creating...") : qsTr("Create")
enabled: !root.busy
onClicked: root.createRequested(!privateSwitch.checked)
}
}
}
}

View File

@ -0,0 +1,190 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
Popup {
id: root
property string walletHome: ""
property string mnemonic: ""
property string errorText: ""
property bool busy: false
signal createRequested(string password)
signal copyRequested(string text)
modal: true
dim: true
parent: Overlay.overlay
width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420))
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
closePolicy: root.busy || root.mnemonic.length > 0
? Popup.NoAutoClose
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
onOpened: {
passwordField.text = ""
confirmField.text = ""
acknowledgement.checked = false
root.errorText = ""
root.mnemonic = ""
passwordField.forceActiveFocus()
}
background: Rectangle {
color: "#18181b"
border.color: "#3f3f46"
border.width: 1
radius: 8
}
contentItem: ColumnLayout {
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 14
visible: root.mnemonic.length === 0
Label {
Layout.fillWidth: true
text: qsTr("Create wallet")
color: "#f4f4f5"
font.bold: true
font.pixelSize: 17
}
Label {
Layout.fillWidth: true
text: root.walletHome.length > 0
? qsTr("Wallet will be stored at %1").arg(root.walletHome)
: qsTr("Wallet will use default storage")
color: "#a1a1aa"
wrapMode: Text.WordWrap
}
TextField {
id: passwordField
objectName: "walletPasswordField"
Layout.fillWidth: true
placeholderText: qsTr("Password")
echoMode: TextInput.Password
enabled: !root.busy
Keys.onReturnPressed: createButton.tryCreate()
}
TextField {
id: confirmField
objectName: "walletConfirmPasswordField"
Layout.fillWidth: true
placeholderText: qsTr("Confirm password")
echoMode: TextInput.Password
enabled: !root.busy
Keys.onReturnPressed: createButton.tryCreate()
}
Label {
Layout.fillWidth: true
visible: root.errorText.length > 0
text: root.errorText
color: "#f87171"
wrapMode: Text.WordWrap
}
RowLayout {
Layout.fillWidth: true
spacing: 10
Button {
Layout.fillWidth: true
text: qsTr("Cancel")
enabled: !root.busy
onClicked: root.close()
}
Button {
id: createButton
objectName: "createWalletButton"
Layout.fillWidth: true
text: root.busy ? qsTr("Creating...") : qsTr("Create wallet")
enabled: !root.busy
function tryCreate() {
if (passwordField.text.length === 0) {
root.errorText = qsTr("Password cannot be empty.")
} else if (passwordField.text !== confirmField.text) {
root.errorText = qsTr("Passwords do not match.")
} else {
root.errorText = ""
root.createRequested(passwordField.text)
}
}
onClicked: tryCreate()
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 14
visible: root.mnemonic.length > 0
Label {
Layout.fillWidth: true
text: qsTr("Back up recovery phrase")
color: "#f4f4f5"
font.bold: true
font.pixelSize: 17
}
Label {
Layout.fillWidth: true
text: qsTr("Write these words down in order. Anyone with this phrase can control this wallet.")
color: "#d4d4d8"
wrapMode: Text.WordWrap
}
Rectangle {
Layout.fillWidth: true
implicitHeight: phraseLabel.implicitHeight + 24
color: "#27272a"
radius: 6
Label {
id: phraseLabel
objectName: "walletMnemonic"
anchors.fill: parent
anchors.margins: 12
text: root.mnemonic
color: "#f4f4f5"
font.bold: true
wrapMode: Text.WordWrap
}
}
Button {
Layout.fillWidth: true
text: qsTr("Copy recovery phrase")
onClicked: root.copyRequested(root.mnemonic)
}
CheckBox {
id: acknowledgement
objectName: "walletBackupAcknowledgement"
Layout.fillWidth: true
text: qsTr("I have safely backed up my recovery phrase")
}
Button {
objectName: "walletContinueButton"
Layout.fillWidth: true
text: qsTr("Continue")
enabled: acknowledgement.checked
onClicked: root.close()
}
}
}
}

View File

@ -0,0 +1,31 @@
import QtQuick
import QtQuick.Controls.Basic
Button {
id: root
property url iconSource
property string accessibleName: ""
property color iconColor: "#d4d4d8"
implicitWidth: 36
implicitHeight: 36
display: AbstractButton.IconOnly
hoverEnabled: true
Accessible.name: root.accessibleName
ToolTip.text: root.accessibleName
ToolTip.visible: root.hovered && root.accessibleName.length > 0
icon.source: root.iconSource
icon.width: 18
icon.height: 18
icon.color: root.iconColor
background: Rectangle {
color: root.pressed ? "#3f3f46" : root.hovered || root.activeFocus ? "#27272a" : "transparent"
radius: 6
border.width: root.activeFocus ? 1 : 0
border.color: "#f26a21"
}
}

View File

@ -0,0 +1,52 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
Popup {
id: root
property string title: qsTr("Wallet error")
property string message: ""
modal: true
dim: true
parent: Overlay.overlay
width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380))
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
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: "#18181b"
border.color: "#3f3f46"
border.width: 1
radius: 8
}
contentItem: ColumnLayout {
spacing: 16
Label {
Layout.fillWidth: true
text: root.title
color: "#f4f4f5"
font.bold: true
font.pixelSize: 17
wrapMode: Text.WordWrap
}
Label {
Layout.fillWidth: true
text: root.message
color: "#d4d4d8"
wrapMode: Text.WordWrap
}
Button {
Layout.alignment: Qt.AlignRight
text: qsTr("Close")
onClicked: root.close()
}
}
}

View File

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" fill="#000"/><path d="M4 20c0-3.866 3.582-6 8-6s8 2.134 8 6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 253 B

View File

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M15 5l-7 7 7 7"/></svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@ -0,0 +1 @@
<svg fill="none" height="17" viewBox="0 0 16 17" width="16" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m6.76413 10.1901 5.56067-5.41122c.382-.37171 1-.37297 1.3863.00299.3837.37336.3857.97674.0031 1.34906l-6.25845 6.09017c-.18993.1849-.43822.2781-.68737.2789-.25655-.0019-.50554-.0937-.69254-.2756l-2.79161-2.7166c-.38013-.36991-.37994-.96985.00641-1.34581.38367-.37336 1.00799-.37115 1.38299-.00623z" fill="#000" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 463 B

View File

@ -0,0 +1 @@
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#000"><path d="m4.16634 7c.27614 0 .5-.22386.5-.5s-.22386-.5-.5-.5h-.16667c-1.47275 0-2.66666 1.19391-2.66666 2.66667v3.33333c0 1.4728 1.19391 2.6667 2.66666 2.6667h3.33334c1.47276 0 2.66666-1.1939 2.66666-2.6667v-.1667c0-.2761-.22385-.5-.5-.5-.27614 0-.5.2239-.5.5v.1667c0 .9205-.74619 1.6667-1.66666 1.6667h-3.33334c-.92047 0-1.66666-.7462-1.66666-1.6667v-3.33333c0-.92048.74619-1.66667 1.66666-1.66667z"/><path clip-rule="evenodd" d="m5.99967 4c0-1.47276 1.19391-2.66666 2.66667-2.66666h3.33336c1.4727 0 2.6666 1.1939 2.6666 2.66666v3.33334c0 1.47276-1.1939 2.66666-2.6666 2.66666h-3.33336c-1.47276 0-2.66667-1.1939-2.66667-2.66666zm2.66667-1.66666h3.33336c.9204 0 1.6666.74619 1.6666 1.66666v3.33334c0 .92047-.7462 1.66666-1.6666 1.66666h-3.33336c-.92047 0-1.66667-.74619-1.66667-1.66666v-3.33334c0-.92047.7462-1.66666 1.66667-1.66666z" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 977 B

View File

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M12 3v9"/><path d="M7.05 7.05a7 7 0 1 0 9.9 0"/></svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@ -0,0 +1,382 @@
#include "LogosWalletProvider.h"
#include <QDir>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QVariantList>
#include <QVariantMap>
#include "logos_sdk.h"
namespace {
constexpr int WALLET_FFI_SUCCESS = 0;
bool isHex(const QString& value, qsizetype size, bool lowercaseOnly = true)
{
if (value.size() != size)
return false;
for (const QChar character : value) {
const bool digit = character >= QLatin1Char('0')
&& character <= QLatin1Char('9');
const bool lowercase = character >= QLatin1Char('a')
&& character <= QLatin1Char('f');
const bool uppercase = character >= QLatin1Char('A')
&& character <= QLatin1Char('F');
if (!digit && !lowercase && (!uppercase || lowercaseOnly))
return false;
}
return true;
}
QString littleEndianU128ToDecimal(const QString& value)
{
if (!isHex(value, 32))
return {};
QString decimal = QStringLiteral("0");
for (int byteIndex = 15; byteIndex >= 0; --byteIndex) {
bool parsed = false;
int carry = value.mid(byteIndex * 2, 2).toInt(&parsed, 16);
if (!parsed)
return {};
for (qsizetype digit = decimal.size(); digit-- > 0;) {
const int next = (decimal.at(digit).unicode() - QLatin1Char('0').unicode())
* 256 + carry;
decimal[digit] = QLatin1Char(static_cast<char>('0' + next % 10));
carry = next / 10;
}
while (carry > 0) {
decimal.prepend(QLatin1Char(static_cast<char>('0' + carry % 10)));
carry /= 10;
}
}
return decimal;
}
WalletSession failedSession(WalletFailure failure)
{
WalletSession session;
session.failure = failure;
session.snapshot.failure = failure;
return session;
}
WalletCreation failedCreation(WalletFailure failure)
{
WalletCreation creation;
creation.failure = failure;
creation.snapshot.failure = failure;
return creation;
}
}
struct LogosWalletProvider::Impl {
explicit Impl(LogosAPI* api)
: ownedLogos(std::make_unique<LogosModules>(api)), logos(ownedLogos.get())
{
}
explicit Impl(LogosModules* value)
: logos(value)
{
}
std::unique_ptr<LogosModules> ownedLogos;
LogosModules* logos = nullptr;
};
LogosWalletProvider::LogosWalletProvider(LogosAPI* api)
: m_impl(std::make_unique<Impl>(api))
{
}
LogosWalletProvider::LogosWalletProvider(LogosModules* logos)
: m_impl(std::make_unique<Impl>(logos))
{
}
LogosWalletProvider::~LogosWalletProvider()
{
if (m_connected)
save();
}
WalletSession LogosWalletProvider::connect(const WalletPaths& paths)
{
clearSnapshot();
if (!m_impl->logos)
return failedSession(WalletFailure::WalletUnavailable);
WalletSession session;
if (sharedWalletIsOpen()) {
session.adopted = true;
} else {
if (!QFileInfo::exists(paths.storage))
return failedSession(WalletFailure::WalletMissing);
if (m_impl->logos->logos_execution_zone.open(paths.config, paths.storage)
!= WALLET_FFI_SUCCESS) {
return failedSession(WalletFailure::OpenFailed);
}
}
m_connected = true;
session.snapshot = snapshot(true);
session.failure = session.snapshot.failure;
return session;
}
WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths,
const QString& password)
{
clearSnapshot();
if (!m_impl->logos)
return failedCreation(WalletFailure::WalletUnavailable);
const QFileInfo configInfo(paths.config);
const QFileInfo storageInfo(paths.storage);
if (!QDir().mkpath(configInfo.absolutePath())
|| !QDir().mkpath(storageInfo.absolutePath())) {
return failedCreation(WalletFailure::CreateFailed);
}
WalletCreation creation;
creation.mnemonic = m_impl->logos->logos_execution_zone.create_new(
paths.config, paths.storage, password);
if (creation.mnemonic.isEmpty())
return failedCreation(WalletFailure::CreateFailed);
m_connected = true;
if (!save()) {
creation.failure = WalletFailure::SaveFailed;
creation.snapshot.failure = creation.failure;
return creation;
}
creation.snapshot = snapshot(true);
creation.failure = creation.snapshot.failure;
return creation;
}
WalletSnapshot LogosWalletProvider::snapshot(bool forceRefresh)
{
if (m_snapshotReady && !forceRefresh)
return m_snapshot;
if (!m_connected) {
WalletSnapshot result;
result.failure = WalletFailure::WalletUnavailable;
return result;
}
WalletSnapshot result = loadSnapshot();
if (result.ok()) {
m_snapshot = result;
m_snapshotReady = true;
}
return result;
}
void LogosWalletProvider::clearSnapshot()
{
m_snapshot = {};
m_snapshotReady = false;
}
WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic)
{
WalletAccountCreation creation;
if (!m_connected || !m_impl->logos) {
creation.failure = WalletFailure::WalletUnavailable;
return creation;
}
creation.accountId = isPublic
? m_impl->logos->logos_execution_zone.create_account_public()
: m_impl->logos->logos_execution_zone.create_account_private();
if (!isHex(creation.accountId, 64)) {
creation.failure = WalletFailure::CreateFailed;
return creation;
}
if (!save()) {
creation.failure = WalletFailure::SaveFailed;
return creation;
}
if (isPublic)
creation.publicAccount = readPublicAccount(creation.accountId);
clearSnapshot();
creation.snapshot = snapshot(true);
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;
}
WalletSubmission LogosWalletProvider::submitPublicTransaction(
const WalletTransaction& transaction)
{
WalletSubmission submission;
if (!m_connected || !m_impl->logos) {
submission.failure = WalletFailure::WalletUnavailable;
return submission;
}
if (!isHex(transaction.programId, 64)
|| transaction.accountIds.size() != transaction.signingRequirements.size()) {
submission.failure = WalletFailure::InvalidRequest;
return submission;
}
for (const QString& accountId : transaction.accountIds) {
if (!isHex(accountId, 64)) {
submission.failure = WalletFailure::InvalidRequest;
return submission;
}
}
QVariantList signingRequirements;
signingRequirements.reserve(transaction.signingRequirements.size());
for (bool required : transaction.signingRequirements)
signingRequirements.append(required);
QVariantList instruction;
instruction.reserve(transaction.instruction.size());
for (quint32 word : transaction.instruction)
instruction.append(word);
const QString response =
m_impl->logos->logos_execution_zone.send_generic_public_transaction(
transaction.accountIds,
signingRequirements,
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;
}
void LogosWalletProvider::disconnect()
{
if (m_connected)
save();
clearSnapshot();
m_connected = false;
}
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();
}
WalletSnapshot LogosWalletProvider::loadSnapshot()
{
WalletSnapshot result;
result.currentBlockHeight = static_cast<quint64>(
qMax(0, m_impl->logos->logos_execution_zone.get_current_block_height()));
if (result.currentBlockHeight > 0
&& m_impl->logos->logos_execution_zone.sync_to_block(result.currentBlockHeight)
!= WALLET_FFI_SUCCESS) {
result.failure = WalletFailure::ReadFailed;
return result;
}
result.lastSyncedBlock = static_cast<quint64>(
qMax(0, m_impl->logos->logos_execution_zone.get_last_synced_block()));
result.sequencerAddress = m_impl->logos->logos_execution_zone.get_sequencer_addr();
const QVariantList entries = m_impl->logos->logos_execution_zone.list_accounts();
result.accounts.reserve(entries.size());
result.publicAccountReads.reserve(entries.size());
for (const QVariant& value : entries) {
const QVariantMap entry = value.toMap();
const QString address = entry.value(QStringLiteral("account_id")).toString();
if (entry.isEmpty() || !isHex(address, 64)) {
result.failure = WalletFailure::ReadFailed;
return result;
}
WalletAccount account;
account.address = address;
account.isPublic = entry.value(QStringLiteral("is_public"), true).toBool();
if (account.isPublic) {
const WalletAccountRead read = readPublicAccount(address);
result.publicAccountReads.append(read);
account.balance = read.ok()
? littleEndianU128ToDecimal(read.balanceHex)
: m_impl->logos->logos_execution_zone.get_balance(address, true);
} else {
account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false);
}
result.accounts.append(account);
}
if (!save())
result.failure = WalletFailure::SaveFailed;
return result;
}
bool LogosWalletProvider::save() const
{
return m_impl->logos
&& m_impl->logos->logos_execution_zone.save() == WALLET_FFI_SUCCESS;
}

View File

@ -0,0 +1,37 @@
#pragma once
#include <memory>
#include "WalletProvider.h"
class LogosAPI;
struct LogosModules;
class LogosWalletProvider final : public WalletProvider {
public:
explicit LogosWalletProvider(LogosAPI* api);
explicit LogosWalletProvider(LogosModules* logos);
~LogosWalletProvider() override;
WalletSession connect(const WalletPaths& paths) override;
WalletCreation createWallet(const WalletPaths& paths,
const QString& password) override;
WalletSnapshot snapshot(bool forceRefresh = false) override;
void clearSnapshot() override;
WalletAccountCreation createAccount(bool isPublic) override;
WalletAccountRead readPublicAccount(const QString& accountId) const override;
WalletSubmission submitPublicTransaction(
const WalletTransaction& transaction) override;
void disconnect() override;
private:
bool sharedWalletIsOpen() const;
WalletSnapshot loadSnapshot();
bool save() const;
struct Impl;
std::unique_ptr<Impl> m_impl;
WalletSnapshot m_snapshot;
bool m_snapshotReady = false;
bool m_connected = false;
};

View File

@ -0,0 +1,61 @@
#include "WalletAccountModel.h"
WalletAccountModel::WalletAccountModel(QObject* parent)
: QAbstractListModel(parent)
{
}
int WalletAccountModel::rowCount(const QModelIndex& parent) const
{
return parent.isValid() ? 0 : m_accounts.size();
}
QVariant WalletAccountModel::data(const QModelIndex& index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_accounts.size())
return {};
const Entry& account = m_accounts.at(index.row());
switch (role) {
case NameRole:
return account.name;
case AddressRole:
return account.address;
case BalanceRole:
return account.balance;
case IsPublicRole:
return account.isPublic;
default:
return {};
}
}
QHash<int, QByteArray> WalletAccountModel::roleNames() const
{
return {
{ NameRole, "name" },
{ AddressRole, "address" },
{ BalanceRole, "balance" },
{ IsPublicRole, "isPublic" },
};
}
void WalletAccountModel::replaceAccounts(const QVector<WalletAccount>& accounts)
{
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,
});
}
endResetModel();
if (oldCount != m_accounts.size())
emit countChanged();
}

View File

@ -0,0 +1,42 @@
#pragma once
#include <QAbstractListModel>
#include <QVector>
#include "WalletProvider.h"
class WalletAccountModel final : public QAbstractListModel {
Q_OBJECT
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
enum Role {
NameRole = Qt::UserRole + 1,
AddressRole,
BalanceRole,
IsPublicRole,
};
Q_ENUM(Role)
explicit WalletAccountModel(QObject* parent = nullptr);
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
void replaceAccounts(const QVector<WalletAccount>& accounts);
int count() const { return m_accounts.size(); }
signals:
void countChanged();
private:
struct Entry {
QString name;
QString address;
QString balance;
bool isPublic = true;
};
QVector<Entry> m_accounts;
};

View File

@ -0,0 +1,26 @@
#include "WalletProvider.h"
QString walletFailureCode(WalletFailure failure)
{
switch (failure) {
case WalletFailure::None:
return {};
case WalletFailure::WalletMissing:
return QStringLiteral("wallet_missing");
case WalletFailure::WalletUnavailable:
return QStringLiteral("wallet_unavailable");
case WalletFailure::OpenFailed:
return QStringLiteral("open_failed");
case WalletFailure::CreateFailed:
return QStringLiteral("create_failed");
case WalletFailure::SaveFailed:
return QStringLiteral("save_failed");
case WalletFailure::ReadFailed:
return QStringLiteral("read_failed");
case WalletFailure::InvalidRequest:
return QStringLiteral("invalid_request");
case WalletFailure::SubmissionFailed:
return QStringLiteral("submission_failed");
}
return QStringLiteral("wallet_unavailable");
}

View File

@ -0,0 +1,107 @@
#pragma once
#include <QString>
#include <QStringList>
#include <QVector>
enum class WalletFailure {
None,
WalletMissing,
WalletUnavailable,
OpenFailed,
CreateFailed,
SaveFailed,
ReadFailed,
InvalidRequest,
SubmissionFailed,
};
QString walletFailureCode(WalletFailure failure);
struct WalletPaths {
QString config;
QString storage;
};
struct WalletAccountRead {
QString accountId;
QString status = QStringLiteral("read_failed");
QString programOwner;
QString balanceHex;
QString nonceHex;
QString dataHex;
bool ok() const { return status == QStringLiteral("ok"); }
};
struct WalletAccount {
QString address;
QString balance;
bool isPublic = true;
};
struct WalletSnapshot {
WalletFailure failure = WalletFailure::None;
QVector<WalletAccount> accounts;
QVector<WalletAccountRead> publicAccountReads;
quint64 lastSyncedBlock = 0;
quint64 currentBlockHeight = 0;
QString sequencerAddress;
bool ok() const { return failure == WalletFailure::None; }
};
struct WalletSession {
WalletFailure failure = WalletFailure::None;
WalletSnapshot snapshot;
bool adopted = false;
bool ok() const { return failure == WalletFailure::None; }
};
struct WalletCreation {
WalletFailure failure = WalletFailure::None;
QString mnemonic;
WalletSnapshot snapshot;
bool ok() const { return failure == WalletFailure::None; }
};
struct WalletAccountCreation {
WalletFailure failure = WalletFailure::None;
QString accountId;
WalletAccountRead publicAccount;
WalletSnapshot snapshot;
bool ok() const { return failure == WalletFailure::None; }
};
struct WalletTransaction {
QString programId;
QStringList accountIds;
QVector<bool> signingRequirements;
QVector<quint32> instruction;
};
struct WalletSubmission {
WalletFailure failure = WalletFailure::None;
QString nativeHash;
bool accepted() const { return failure == WalletFailure::None && !nativeHash.isEmpty(); }
};
class WalletProvider {
public:
virtual ~WalletProvider() = default;
virtual WalletSession connect(const WalletPaths& paths) = 0;
virtual WalletCreation createWallet(const WalletPaths& paths,
const QString& password) = 0;
virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0;
virtual void clearSnapshot() = 0;
virtual WalletAccountCreation createAccount(bool isPublic) = 0;
virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0;
virtual WalletSubmission submitPublicTransaction(
const WalletTransaction& transaction) = 0;
virtual void disconnect() = 0;
};

View File

@ -0,0 +1,367 @@
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QtTest>
#include "FakeWalletProvider.h"
#include "LogosWalletProvider.h"
#include "WalletAccountModel.h"
#include "logos_sdk.h"
namespace {
const QString ACCOUNT_A(64, QLatin1Char('a'));
const QString ACCOUNT_B(64, QLatin1Char('b'));
const QString PROGRAM_ID(64, QLatin1Char('c'));
QString publicAccountJson(const QString& owner = PROGRAM_ID,
const QString& balance = QStringLiteral("01000000000000000000000000000000"),
const QString& nonce = QString(32, QLatin1Char('0')),
const QString& data = QStringLiteral("00ff"))
{
return QString::fromUtf8(QJsonDocument(QJsonObject {
{ QStringLiteral("program_owner"), owner },
{ QStringLiteral("balance"), balance },
{ QStringLiteral("nonce"), nonce },
{ QStringLiteral("data"), data },
}).toJson(QJsonDocument::Compact));
}
QVariantMap accountEntry(const QString& id, bool isPublic)
{
return {
{ QStringLiteral("account_id"), id },
{ QStringLiteral("is_public"), isPublic },
};
}
}
class LogosWalletProviderTest : public QObject {
Q_OBJECT
private slots:
void adoptsOpenWalletAndCachesSnapshots();
void opensConfiguredWalletWhenNoSharedSessionExists();
void createsAndPersistsWallet();
void validatesCompletePublicAccountPayloads();
void fallsBackToBalanceWhenPublicReadFails();
void createsAndPersistsAccounts();
void preservesCreatedAccountWhenPublicReadFails();
void preservesCreatedAccountWhenSnapshotRefreshFails();
void dispatchesExactGenericTransaction();
void rejectsInvalidSubmissionResponses();
void exposesStableAccountModelRoles();
void fakeProviderImplementsConsumerContract();
};
void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.currentBlockHeight = 12;
modules.logos_execution_zone.lastSyncedBlock = 11;
modules.logos_execution_zone.accounts = {
accountEntry(ACCOUNT_A, true),
accountEntry(ACCOUNT_B, false),
};
modules.logos_execution_zone.publicAccounts.insert(
ACCOUNT_A, publicAccountJson());
modules.logos_execution_zone.balances.insert(ACCOUNT_B, QStringLiteral("42"));
LogosWalletProvider provider(&modules);
const WalletSession session = provider.connect({ QStringLiteral("unused"), QStringLiteral("unused") });
QVERIFY(session.ok());
QVERIFY(session.adopted);
QCOMPARE(session.snapshot.accounts.size(), 2);
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.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;
QVERIFY(provider.snapshot().ok());
QCOMPARE(modules.logos_execution_zone.listCalls, listCalls);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls);
QVERIFY(provider.snapshot(true).ok());
QVERIFY(modules.logos_execution_zone.listCalls > listCalls);
QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls);
modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(
PROGRAM_ID, QString(32, QLatin1Char('f')));
QCOMPARE(provider.snapshot(true).accounts.at(0).balance,
QStringLiteral("340282366920938463463374607431768211455"));
provider.clearSnapshot();
const int afterRefresh = modules.logos_execution_zone.listCalls;
QVERIFY(provider.snapshot().ok());
QVERIFY(modules.logos_execution_zone.listCalls > afterRefresh);
provider.disconnect();
QCOMPARE(provider.snapshot().failure, WalletFailure::WalletUnavailable);
}
void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists()
{
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);
const WalletSession session = provider.connect({
directory.filePath(QStringLiteral("wallet.json")),
storage,
});
QVERIFY(session.ok());
QVERIFY(!session.adopted);
QCOMPARE(modules.logos_execution_zone.openCalls, 1);
QCOMPARE(modules.logos_execution_zone.openedStorage, storage);
LogosModules missingModules;
LogosWalletProvider missingProvider(&missingModules);
QCOMPARE(missingProvider.connect({ QStringLiteral("config"), QStringLiteral("missing") }).failure,
WalletFailure::WalletMissing);
}
void LogosWalletProviderTest::createsAndPersistsWallet()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
LogosModules modules;
LogosWalletProvider provider(&modules);
const WalletPaths paths {
directory.filePath(QStringLiteral("config/wallet.json")),
directory.filePath(QStringLiteral("state/storage.json")),
};
const WalletCreation creation = provider.createWallet(paths, QStringLiteral("secret"));
QVERIFY(creation.ok());
QCOMPARE(creation.mnemonic, modules.logos_execution_zone.mnemonic);
QCOMPARE(modules.logos_execution_zone.createdConfig, paths.config);
QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage);
QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret"));
QVERIFY(modules.logos_execution_zone.saveCalls >= 1);
LogosModules rejectedModules;
rejectedModules.logos_execution_zone.mnemonic.clear();
LogosWalletProvider rejectedProvider(&rejectedModules);
QCOMPARE(rejectedProvider.createWallet(paths, QStringLiteral("secret")).failure,
WalletFailure::CreateFailed);
LogosModules unsavedModules;
unsavedModules.logos_execution_zone.saveResult = 1;
LogosWalletProvider unsavedProvider(&unsavedModules);
const WalletCreation unsaved = unsavedProvider.createWallet(paths, QStringLiteral("secret"));
QCOMPARE(unsaved.failure, WalletFailure::SaveFailed);
QCOMPARE(unsaved.mnemonic, unsavedModules.logos_execution_zone.mnemonic);
}
void LogosWalletProviderTest::validatesCompletePublicAccountPayloads()
{
LogosModules modules;
modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson());
LogosWalletProvider provider(&modules);
const WalletAccountRead valid = provider.readPublicAccount(ACCOUNT_A);
QVERIFY(valid.ok());
QCOMPARE(valid.accountId, ACCOUNT_A);
QCOMPARE(valid.programOwner, PROGRAM_ID);
QCOMPARE(valid.balanceHex, QStringLiteral("01000000000000000000000000000000"));
QCOMPARE(valid.dataHex, QStringLiteral("00ff"));
modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(PROGRAM_ID.toUpper());
QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok());
modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(
PROGRAM_ID, QStringLiteral("01"));
QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok());
modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(
PROGRAM_ID, QStringLiteral("01000000000000000000000000000000"),
QString(32, QLatin1Char('0')), QStringLiteral("abc"));
QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok());
modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = QStringLiteral("[]");
QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok());
QVERIFY(!provider.readPublicAccount(QStringLiteral("invalid")).ok());
}
void LogosWalletProviderTest::fallsBackToBalanceWhenPublicReadFails()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
modules.logos_execution_zone.balances.insert(ACCOUNT_A, QStringLiteral("42"));
LogosWalletProvider provider(&modules);
const WalletSession session = provider.connect({});
QVERIFY(session.ok());
QCOMPARE(session.snapshot.accounts.size(), 1);
QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("42"));
QCOMPARE(session.snapshot.publicAccountReads.size(), 1);
QVERIFY(!session.snapshot.publicAccountReads.at(0).ok());
}
void LogosWalletProviderTest::createsAndPersistsAccounts()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.publicAccountId = ACCOUNT_A;
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson());
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
const int savesBeforeCreate = modules.logos_execution_zone.saveCalls;
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);
modules.logos_execution_zone.saveResult = 1;
QCOMPARE(provider.createAccount(true).failure, WalletFailure::SaveFailed);
}
void LogosWalletProviderTest::preservesCreatedAccountWhenPublicReadFails()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.publicAccountId = ACCOUNT_A;
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
modules.logos_execution_zone.balances.insert(ACCOUNT_A, QStringLiteral("7"));
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
const WalletAccountCreation creation = provider.createAccount(true);
QVERIFY(creation.ok());
QCOMPARE(creation.accountId, ACCOUNT_A);
QVERIFY(!creation.publicAccount.ok());
QCOMPARE(creation.snapshot.accounts.size(), 1);
QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7"));
}
void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.publicAccountId = ACCOUNT_A;
modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson());
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
modules.logos_execution_zone.currentBlockHeight = 1;
modules.logos_execution_zone.syncResult = 1;
const WalletAccountCreation creation = provider.createAccount(true);
QVERIFY(creation.ok());
QCOMPARE(creation.accountId, ACCOUNT_A);
QCOMPARE(creation.snapshot.failure, WalletFailure::ReadFailed);
}
void LogosWalletProviderTest::dispatchesExactGenericTransaction()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.transactionResponse = QStringLiteral(
R"({"success":true,"tx_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"})");
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
WalletTransaction transaction;
transaction.programId = PROGRAM_ID;
transaction.accountIds = { ACCOUNT_A, ACCOUNT_B };
transaction.signingRequirements = { true, false };
transaction.instruction = { 7, 0, 4294967295U };
const WalletSubmission submission = provider.submitPublicTransaction(transaction);
QVERIFY(submission.accepted());
QCOMPARE(submission.nativeHash, QString(64, QLatin1Char('a')));
QCOMPARE(modules.logos_execution_zone.submitCalls, 1);
QCOMPARE(modules.logos_execution_zone.submittedProgramId, PROGRAM_ID);
QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds);
QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements,
QVariantList({ true, false }));
QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(),
QVariantList({ 7U, 0U, 4294967295U }));
}
void LogosWalletProviderTest::rejectsInvalidSubmissionResponses()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
WalletTransaction transaction {
PROGRAM_ID,
{ ACCOUNT_A },
{ true },
{ 1 },
};
const QStringList invalidResponses {
QStringLiteral("not-json"),
QStringLiteral(R"({"success":false,"tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"),
QStringLiteral(R"({"success":true,"error":"rejected","tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"),
QStringLiteral(R"({"success":true,"tx_hash":"short"})"),
};
for (const QString& response : invalidResponses) {
modules.logos_execution_zone.transactionResponse = response;
QCOMPARE(provider.submitPublicTransaction(transaction).failure,
WalletFailure::SubmissionFailed);
}
transaction.signingRequirements.clear();
QCOMPARE(provider.submitPublicTransaction(transaction).failure,
WalletFailure::InvalidRequest);
}
void LogosWalletProviderTest::exposesStableAccountModelRoles()
{
WalletAccountModel model;
QSignalSpy countChanged(&model, &WalletAccountModel::countChanged);
model.replaceAccounts({
{ ACCOUNT_A, QStringLiteral("10"), true },
{ ACCOUNT_B, QStringLiteral("20"), false },
});
QCOMPARE(model.count(), 2);
QCOMPARE(countChanged.count(), 1);
QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name"));
QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(),
QStringLiteral("Account 1"));
QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B);
QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(),
QStringLiteral("20"));
QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool());
}
void LogosWalletProviderTest::fakeProviderImplementsConsumerContract()
{
FakeWalletProvider provider;
provider.snapshotResult.accounts = { { ACCOUNT_A, QStringLiteral("5"), true } };
provider.submissionResult.nativeHash = QString(64, QLatin1Char('d'));
QCOMPARE(provider.snapshot(true).accounts.size(), 1);
QVERIFY(provider.lastForceRefresh);
WalletTransaction transaction { PROGRAM_ID, { ACCOUNT_A }, { true }, { 9 } };
QVERIFY(provider.submitPublicTransaction(transaction).accepted());
QCOMPARE(provider.lastTransaction.instruction, transaction.instruction);
provider.disconnect();
QCOMPARE(provider.disconnectCalls, 1);
}
QTEST_GUILESS_MAIN(LogosWalletProviderTest)
#include "LogosWalletProviderTest.moc"

View File

@ -0,0 +1,118 @@
#pragma once
#include <QHash>
#include <QString>
#include <QStringList>
#include <QVariant>
#include <QVariantList>
class LogosAPI;
class FakeExecutionZone {
public:
int openResult = 0;
int saveResult = 0;
int syncResult = 0;
int lastSyncedBlock = 0;
int currentBlockHeight = 0;
QString sequencerAddress;
QString mnemonic = QStringLiteral("one two three");
QString publicAccountId;
QString privateAccountId;
QString transactionResponse;
QVariantList accounts;
QHash<QString, QString> publicAccounts;
QHash<QString, QString> balances;
int openCalls = 0;
int saveCalls = 0;
int syncCalls = 0;
int listCalls = 0;
int publicReadCalls = 0;
int submitCalls = 0;
QString openedConfig;
QString openedStorage;
QString createdConfig;
QString createdStorage;
QString createdPassword;
QStringList submittedAccountIds;
QVariantList submittedSigningRequirements;
QVariant submittedInstruction;
QString submittedProgramId;
int open(const QString& config, const QString& storage)
{
++openCalls;
openedConfig = config;
openedStorage = storage;
return openResult;
}
QString create_new(const QString& config,
const QString& storage,
const QString& password)
{
createdConfig = config;
createdStorage = storage;
createdPassword = password;
return mnemonic;
}
int save()
{
++saveCalls;
return saveResult;
}
QString create_account_public() { return publicAccountId; }
QString create_account_private() { return privateAccountId; }
int get_last_synced_block() const { return lastSyncedBlock; }
int get_current_block_height() const { return currentBlockHeight; }
int sync_to_block(quint64)
{
++syncCalls;
return syncResult;
}
QString get_sequencer_addr() const { return sequencerAddress; }
QVariantList list_accounts()
{
++listCalls;
return accounts;
}
QString get_account_public(const QString& accountId)
{
++publicReadCalls;
return publicAccounts.value(accountId);
}
QString get_balance(const QString& accountId, bool) const
{
return balances.value(accountId);
}
QString send_generic_public_transaction(
const QStringList& accountIds,
const QVariantList& signingRequirements,
const QVariant& instruction,
const QString& programId)
{
++submitCalls;
submittedAccountIds = accountIds;
submittedSigningRequirements = signingRequirements;
submittedInstruction = instruction;
submittedProgramId = programId;
return transactionResponse;
}
};
struct LogosModules {
LogosModules() = default;
explicit LogosModules(LogosAPI*) { }
FakeExecutionZone logos_execution_zone;
};

View File

@ -0,0 +1,3 @@
#include <QtQuickTest/quicktest.h>
QUICK_TEST_MAIN(logos_wallet_qml)

View File

@ -0,0 +1,43 @@
import QtQuick
import QtTest
import Logos.Wallet as Wallet
Item {
id: root
width: 360
height: 400
Component {
id: resultComponent
Wallet.SubmittedTransaction {
width: 280
}
}
TestCase {
name: "SubmittedTransaction"
when: windowShown
function test_presentsBase58AndCopyFeedback() {
const result = createTemporaryObject(resultComponent, root, {
transactionId: "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
})
verify(result, "Submitted result exists")
verify(result.base58Shape)
const label = findChild(result, "submittedTransactionId")
verify(label, "Transaction label exists")
compare(label.text, result.transactionId)
verify(label.implicitHeight > 0)
const copyButton = findChild(result, "copySubmittedTransactionButton")
verify(copyButton, "Copy button exists")
mouseClick(copyButton)
verify(copyButton.copied)
result.transactionId = "0OIl"
verify(!result.base58Shape)
}
}
}

View File

@ -0,0 +1,125 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtTest
import Logos.Wallet as Wallet
Item {
id: root
width: 800
height: 600
Component {
id: summaryComponent
Item {
property var snapshot: ({})
implicitHeight: summaryText.implicitHeight
Label {
id: summaryText
objectName: "customSummaryText"
text: parent.snapshot.amount || ""
}
}
}
Component {
id: dialogComponent
Wallet.TransactionConfirmationDialog {
summary: summaryComponent
}
}
Component {
id: viewportComponent
Item {
width: 360
height: 240
}
}
Component {
id: tallSummaryComponent
Item {
property var snapshot: ({})
implicitHeight: 360
}
}
Component {
id: constrainedDialogComponent
Wallet.TransactionConfirmationDialog {
summary: tallSummaryComponent
}
}
TestCase {
name: "TransactionConfirmationDialog"
when: windowShown
function test_capturesSnapshotAndLoadsProgramSummary() {
const dialog = createTemporaryObject(dialogComponent, root)
verify(dialog, "Dialog exists")
const source = { amount: "10" }
dialog.openWithSnapshot(source)
tryCompare(dialog, "opened", true)
source.amount = "20"
compare(dialog.snapshot.amount, "10")
const summary = findChild(dialog, "customSummaryText")
verify(summary, "Custom summary exists")
compare(summary.text, "10")
confirmedSpy.target = dialog
confirmedSpy.signalName = "confirmed"
confirmedSpy.clear()
mouseClick(findChild(dialog, "transactionConfirmButton"))
compare(confirmedSpy.count, 1)
compare(confirmedSpy.signalArguments[0][0].amount, "10")
tryCompare(dialog, "opened", false)
}
function test_busyStateCannotBeDismissed() {
const dialog = createTemporaryObject(dialogComponent, root)
verify(dialog, "Dialog exists")
dialog.openWithSnapshot({ amount: "5" })
dialog.busy = true
const cancelButton = findChild(dialog, "transactionCancelButton")
const confirmButton = findChild(dialog, "transactionConfirmButton")
verify(!cancelButton.enabled)
verify(!confirmButton.enabled)
keyClick(Qt.Key_Escape)
verify(dialog.opened)
dialog.cancel()
verify(dialog.opened)
dialog.busy = false
dialog.cancel()
tryCompare(dialog, "opened", false)
}
function test_keepsActionsInsideShortViewport() {
const viewport = createTemporaryObject(viewportComponent, root)
verify(viewport, "Short viewport exists")
const dialog = createTemporaryObject(constrainedDialogComponent, viewport)
verify(dialog, "Dialog exists")
dialog.openWithSnapshot({ amount: "5" })
tryCompare(dialog, "opened", true)
verify(dialog.height <= viewport.height - 32)
verify(dialog.y >= 0)
verify(dialog.y + dialog.height <= viewport.height)
const confirmButton = findChild(dialog, "transactionConfirmButton")
verify(confirmButton, "Confirm button exists")
verify(confirmButton.visible)
}
}
SignalSpy { id: confirmedSpy }
}

View File

@ -0,0 +1,339 @@
import QtQuick
import QtTest
import Logos.Wallet as Wallet
Item {
id: root
width: 800
height: 600
Component {
id: backendComponent
QtObject {
property bool isWalletOpen: false
property bool walletExists: true
property string walletHome: "/wallet"
property int openCalls: 0
property int createCalls: 0
property int publicAccountCalls: 0
property int privateAccountCalls: 0
property int disconnectCalls: 0
function openExisting() {
openCalls++
isWalletOpen = true
return true
}
function createNewDefault(_password) {
createCalls++
isWalletOpen = true
return "alpha beta gamma"
}
function createAccountPublic() {
publicAccountCalls++
return "a".repeat(64)
}
function createAccountPrivate() {
privateAccountCalls++
return "b".repeat(64)
}
function disconnectWallet() {
disconnectCalls++
isWalletOpen = false
}
}
}
Component {
id: modelComponent
ListModel { }
}
Component {
id: controlComponent
Wallet.WalletControl {
width: 320
height: implicitHeight
viewportWidth: 800
}
}
Component {
id: compactWindowComponent
Window {
width: 360
height: 240
visible: true
property alias control: walletControl
Wallet.WalletControl {
id: walletControl
x: 20
y: 8
width: 320
height: implicitHeight
viewportWidth: parent.width
}
}
}
Component {
id: compactDialogWindowComponent
Window {
width: 360
height: 600
visible: true
property alias control: walletControl
Wallet.WalletControl {
id: walletControl
x: parent.width - width - 12
y: 8
width: 40
height: implicitHeight
viewportWidth: parent.width
}
}
}
TestCase {
name: "WalletControl"
when: windowShown
function createControl(walletProperties, accounts) {
const backend = createTemporaryObject(backendComponent, root, walletProperties || {})
verify(backend, "Backend exists")
const model = createTemporaryObject(modelComponent, root)
verify(model, "Account model exists")
for (const account of accounts || [])
model.append(account)
const control = createTemporaryObject(controlComponent, root, {
wallet: backend,
accountModel: model
})
verify(control, "Wallet control exists")
return { backend, model, control }
}
function test_opensExistingWallet() {
const fixture = createControl({ walletExists: true }, [])
const connectButton = findChild(fixture.control, "walletConnectButton")
verify(connectButton, "Connect button exists")
mouseClick(connectButton)
compare(fixture.backend.openCalls, 1)
tryCompare(fixture.control, "connected", true)
}
function test_requiresSeedBackupAcknowledgement() {
const fixture = createControl({ walletExists: false }, [])
mouseClick(findChild(fixture.control, "walletConnectButton"))
const dialog = findChild(fixture.control, "createWalletDialog")
tryCompare(dialog, "opened", true)
const password = findChild(dialog, "walletPasswordField")
const confirmation = findChild(dialog, "walletConfirmPasswordField")
const createButton = findChild(dialog, "createWalletButton")
verify(password && confirmation && createButton, "Wallet fields exist")
password.text = "secret"
confirmation.text = "different"
mouseClick(createButton)
compare(fixture.backend.createCalls, 0)
verify(dialog.errorText.length > 0)
confirmation.text = "secret"
mouseClick(createButton)
compare(fixture.backend.createCalls, 1)
compare(dialog.mnemonic, "alpha beta gamma")
const acknowledgement = findChild(dialog, "walletBackupAcknowledgement")
const continueButton = findChild(dialog, "walletContinueButton")
verify(acknowledgement && continueButton, "Backup controls exist")
verify(!continueButton.enabled)
mouseClick(acknowledgement)
verify(continueButton.enabled)
mouseClick(continueButton)
tryCompare(dialog, "opened", false)
}
function test_clampsSelectionAndDisconnectsLocally() {
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 }
])
fixture.control.selectedIndex = 1
compare(fixture.control.selectedAddress, "b".repeat(64))
fixture.model.clear()
tryCompare(fixture.control, "selectedIndex", 0)
compare(fixture.control.selectedAddress, "")
fixture.model.append({
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 })
mouseClick(disconnectButton)
compare(fixture.backend.disconnectCalls, 1)
tryCompare(fixture.control, "connected", false)
}
function test_connectedButtonClosesOpenMenu() {
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)
mouseClick(accountButton)
tryCompare(menu, "opened", false)
}
function test_openMenuTracksControlMovement() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
])
fixture.control.x = 20
fixture.control.y = 20
const accountButton = findChild(fixture.control, "walletAccountButton")
const menu = findChild(fixture.control, "walletMenu")
mouseClick(accountButton)
tryCompare(menu, "opened", true)
const initialMenu = menu.contentItem.mapToItem(root, 0, 0)
const initialButton = accountButton.mapToItem(root, 0, 0)
fixture.control.x += 300
fixture.control.y += 30
wait(0)
const movedMenu = menu.contentItem.mapToItem(root, 0, 0)
const movedButton = accountButton.mapToItem(root, 0, 0)
compare(movedMenu.x - movedButton.x, initialMenu.x - initialButton.x)
compare(movedMenu.y - movedButton.y, initialMenu.y - initialButton.y)
}
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 }
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const accountsButton = findChild(fixture.control, "walletAccountsButton")
tryVerify(function() { return accountsButton.visible })
mouseClick(accountsButton)
const accountList = findChild(fixture.control, "walletAccountList")
tryCompare(accountList, "count", 2)
tryVerify(function() { return accountList.itemAtIndex(1) !== null })
const secondAccount = accountList.itemAtIndex(1)
secondAccount.clicked()
tryCompare(fixture.control, "selectedIndex", 1)
compare(fixture.control.selectedAddress, "b".repeat(64))
}
function test_createsAccount() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const accountsButton = findChild(fixture.control, "walletAccountsButton")
tryVerify(function() { return accountsButton.visible })
mouseClick(accountsButton)
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()
compare(fixture.backend.publicAccountCalls, 1)
tryCompare(dialog, "opened", false)
}
function test_compactLayoutHasStableWidth() {
const fixture = createControl({ isWalletOpen: false }, [])
fixture.control.viewportWidth = 480
verify(fixture.control.compactLayout)
compare(fixture.control.implicitWidth, 40)
fixture.control.viewportWidth = 900
verify(!fixture.control.compactLayout)
compare(fixture.control.implicitWidth, 108)
}
function test_walletDialogsUseWindowViewport() {
const window = createTemporaryObject(compactDialogWindowComponent, root)
verify(window, "Compact window exists")
waitForRendering(window.contentItem)
const dialogNames = [
"createWalletDialog",
"createAccountDialog",
"walletMessageDialog"
]
for (const name of dialogNames) {
const dialog = findChild(window.control, name)
verify(dialog, name + " exists")
dialog.open()
tryCompare(dialog, "opened", true)
compare(dialog.width, window.width - 32)
verify(dialog.x >= 0)
verify(dialog.x + dialog.width <= window.width)
dialog.close()
tryCompare(dialog, "opened", false)
}
}
function test_accountsRemainReachableInShortWindow() {
const backend = createTemporaryObject(backendComponent, root, { isWalletOpen: true })
const model = createTemporaryObject(modelComponent, root)
verify(backend && model, "Wallet fixture exists")
for (let index = 0; index < 10; ++index) {
model.append({
name: "Account " + index,
address: String(index).repeat(64),
balance: String(index),
isPublic: true
})
}
const window = createTemporaryObject(compactWindowComponent, root)
verify(window, "Short window exists")
window.control.wallet = backend
window.control.accountModel = model
waitForRendering(window.contentItem)
mouseClick(findChild(window.control, "walletAccountButton"))
const menu = findChild(window.control, "walletMenu")
tryCompare(menu, "opened", true)
mouseClick(findChild(window.control, "walletAccountsButton"))
const accountList = findChild(window.control, "walletAccountList")
const addButton = findChild(window.control, "walletAddAccountButton")
tryCompare(accountList, "count", 10)
verify(menu.y >= 12, "Menu top: " + menu.y)
verify(menu.y + menu.height <= window.height - 12,
"Menu bottom: " + (menu.y + menu.height)
+ ", window: " + window.height)
verify(accountList.height > 0)
verify(accountList.contentHeight > accountList.height)
verify(addButton && addButton.visible, "Add account button is visible")
const addPosition = addButton.mapToItem(window.contentItem, 0, 0)
verify(addPosition.y >= menu.y)
verify(addPosition.y + addButton.height <= menu.y + menu.height,
"Add account bottom: " + (addPosition.y + addButton.height)
+ ", menu bottom: " + (menu.y + menu.height))
}
}
}

View File

@ -0,0 +1,73 @@
#pragma once
#include "WalletProvider.h"
class FakeWalletProvider final : public WalletProvider {
public:
WalletSession connectResult;
WalletCreation createWalletResult;
WalletSnapshot snapshotResult;
WalletAccountCreation createAccountResult;
WalletAccountRead readResult;
WalletSubmission submissionResult;
int connectCalls = 0;
int createWalletCalls = 0;
int snapshotCalls = 0;
int clearCalls = 0;
int createAccountCalls = 0;
mutable int readCalls = 0;
int submitCalls = 0;
int disconnectCalls = 0;
bool lastForceRefresh = false;
bool lastAccountWasPublic = false;
WalletPaths lastPaths;
WalletTransaction lastTransaction;
WalletSession connect(const WalletPaths& paths) override
{
++connectCalls;
lastPaths = paths;
return connectResult;
}
WalletCreation createWallet(const WalletPaths& paths,
const QString&) override
{
++createWalletCalls;
lastPaths = paths;
return createWalletResult;
}
WalletSnapshot snapshot(bool forceRefresh) override
{
++snapshotCalls;
lastForceRefresh = forceRefresh;
return snapshotResult;
}
void clearSnapshot() override { ++clearCalls; }
WalletAccountCreation createAccount(bool isPublic) override
{
++createAccountCalls;
lastAccountWasPublic = isPublic;
return createAccountResult;
}
WalletAccountRead readPublicAccount(const QString&) const override
{
++readCalls;
return readResult;
}
WalletSubmission submitPublicTransaction(
const WalletTransaction& transaction) override
{
++submitCalls;
lastTransaction = transaction;
return submissionResult;
}
void disconnect() override { ++disconnectCalls; }
};