From ea4fcc2c6c72f5c2a9ce13fd7d808821e9370cdd Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 15 Jul 2026 11:55:39 -0300 Subject: [PATCH 01/14] 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 --- apps/shared/wallet/CMakeLists.txt | 154 ++++++ .../wallet/qml/SubmittedTransaction.qml | 86 +++ .../qml/TransactionConfirmationDialog.qml | 169 ++++++ apps/shared/wallet/qml/WalletControl.qml | 503 ++++++++++++++++++ .../wallet/qml/internal/AccountDelegate.qml | 77 +++ .../shared/wallet/qml/internal/CopyButton.qml | 26 + .../qml/internal/CreateAccountDialog.qml | 94 ++++ .../qml/internal/CreateWalletDialog.qml | 190 +++++++ .../wallet/qml/internal/WalletIconButton.qml | 31 ++ .../qml/internal/WalletMessageDialog.qml | 52 ++ .../wallet/qml/internal/icons/account.svg | 1 + .../shared/wallet/qml/internal/icons/back.svg | 1 + .../wallet/qml/internal/icons/checkmark.svg | 1 + .../shared/wallet/qml/internal/icons/copy.svg | 1 + .../wallet/qml/internal/icons/power.svg | 1 + .../shared/wallet/src/LogosWalletProvider.cpp | 382 +++++++++++++ apps/shared/wallet/src/LogosWalletProvider.h | 37 ++ apps/shared/wallet/src/WalletAccountModel.cpp | 61 +++ apps/shared/wallet/src/WalletAccountModel.h | 42 ++ apps/shared/wallet/src/WalletProvider.cpp | 26 + apps/shared/wallet/src/WalletProvider.h | 107 ++++ .../tests/cpp/LogosWalletProviderTest.cpp | 367 +++++++++++++ .../wallet/tests/cpp/fixtures/logos_sdk.h | 118 ++++ apps/shared/wallet/tests/qml/main.cpp | 3 + .../tests/qml/tst_SubmittedTransaction.qml | 43 ++ .../qml/tst_TransactionConfirmationDialog.qml | 125 +++++ .../wallet/tests/qml/tst_WalletControl.qml | 339 ++++++++++++ .../wallet/tests/support/FakeWalletProvider.h | 73 +++ 28 files changed, 3110 insertions(+) create mode 100644 apps/shared/wallet/CMakeLists.txt create mode 100644 apps/shared/wallet/qml/SubmittedTransaction.qml create mode 100644 apps/shared/wallet/qml/TransactionConfirmationDialog.qml create mode 100644 apps/shared/wallet/qml/WalletControl.qml create mode 100644 apps/shared/wallet/qml/internal/AccountDelegate.qml create mode 100644 apps/shared/wallet/qml/internal/CopyButton.qml create mode 100644 apps/shared/wallet/qml/internal/CreateAccountDialog.qml create mode 100644 apps/shared/wallet/qml/internal/CreateWalletDialog.qml create mode 100644 apps/shared/wallet/qml/internal/WalletIconButton.qml create mode 100644 apps/shared/wallet/qml/internal/WalletMessageDialog.qml create mode 100644 apps/shared/wallet/qml/internal/icons/account.svg create mode 100644 apps/shared/wallet/qml/internal/icons/back.svg create mode 100644 apps/shared/wallet/qml/internal/icons/checkmark.svg create mode 100644 apps/shared/wallet/qml/internal/icons/copy.svg create mode 100644 apps/shared/wallet/qml/internal/icons/power.svg create mode 100644 apps/shared/wallet/src/LogosWalletProvider.cpp create mode 100644 apps/shared/wallet/src/LogosWalletProvider.h create mode 100644 apps/shared/wallet/src/WalletAccountModel.cpp create mode 100644 apps/shared/wallet/src/WalletAccountModel.h create mode 100644 apps/shared/wallet/src/WalletProvider.cpp create mode 100644 apps/shared/wallet/src/WalletProvider.h create mode 100644 apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp create mode 100644 apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h create mode 100644 apps/shared/wallet/tests/qml/main.cpp create mode 100644 apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml create mode 100644 apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml create mode 100644 apps/shared/wallet/tests/qml/tst_WalletControl.qml create mode 100644 apps/shared/wallet/tests/support/FakeWalletProvider.h diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt new file mode 100644 index 00000000..5c5403ff --- /dev/null +++ b/apps/shared/wallet/CMakeLists.txt @@ -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 + "$" + 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() diff --git a/apps/shared/wallet/qml/SubmittedTransaction.qml b/apps/shared/wallet/qml/SubmittedTransaction.qml new file mode 100644 index 00000000..00cc3ba8 --- /dev/null +++ b/apps/shared/wallet/qml/SubmittedTransaction.qml @@ -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() + } + } + } + } +} diff --git a/apps/shared/wallet/qml/TransactionConfirmationDialog.qml b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml new file mode 100644 index 00000000..141a8db8 --- /dev/null +++ b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml @@ -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 + } + } + } + } +} diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml new file mode 100644 index 00000000..28d32a13 --- /dev/null +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -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" + } +} diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml new file mode 100644 index 00000000..b0ff529f --- /dev/null +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -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) + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/CopyButton.qml b/apps/shared/wallet/qml/internal/CopyButton.qml new file mode 100644 index 00000000..1964da3c --- /dev/null +++ b/apps/shared/wallet/qml/internal/CopyButton.qml @@ -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() + } +} diff --git a/apps/shared/wallet/qml/internal/CreateAccountDialog.qml b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml new file mode 100644 index 00000000..51d5f31f --- /dev/null +++ b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml @@ -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) + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/CreateWalletDialog.qml b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml new file mode 100644 index 00000000..ac422c1f --- /dev/null +++ b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml @@ -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() + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/WalletIconButton.qml b/apps/shared/wallet/qml/internal/WalletIconButton.qml new file mode 100644 index 00000000..159d5e10 --- /dev/null +++ b/apps/shared/wallet/qml/internal/WalletIconButton.qml @@ -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" + } +} diff --git a/apps/shared/wallet/qml/internal/WalletMessageDialog.qml b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml new file mode 100644 index 00000000..7f7a3467 --- /dev/null +++ b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml @@ -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() + } + } +} diff --git a/apps/shared/wallet/qml/internal/icons/account.svg b/apps/shared/wallet/qml/internal/icons/account.svg new file mode 100644 index 00000000..779d76ac --- /dev/null +++ b/apps/shared/wallet/qml/internal/icons/account.svg @@ -0,0 +1 @@ + diff --git a/apps/shared/wallet/qml/internal/icons/back.svg b/apps/shared/wallet/qml/internal/icons/back.svg new file mode 100644 index 00000000..69608756 --- /dev/null +++ b/apps/shared/wallet/qml/internal/icons/back.svg @@ -0,0 +1 @@ + diff --git a/apps/shared/wallet/qml/internal/icons/checkmark.svg b/apps/shared/wallet/qml/internal/icons/checkmark.svg new file mode 100644 index 00000000..2eabb54f --- /dev/null +++ b/apps/shared/wallet/qml/internal/icons/checkmark.svg @@ -0,0 +1 @@ + diff --git a/apps/shared/wallet/qml/internal/icons/copy.svg b/apps/shared/wallet/qml/internal/icons/copy.svg new file mode 100644 index 00000000..cfa6cf8b --- /dev/null +++ b/apps/shared/wallet/qml/internal/icons/copy.svg @@ -0,0 +1 @@ + diff --git a/apps/shared/wallet/qml/internal/icons/power.svg b/apps/shared/wallet/qml/internal/icons/power.svg new file mode 100644 index 00000000..5900ca36 --- /dev/null +++ b/apps/shared/wallet/qml/internal/icons/power.svg @@ -0,0 +1 @@ + diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp new file mode 100644 index 00000000..3e569525 --- /dev/null +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -0,0 +1,382 @@ +#include "LogosWalletProvider.h" + +#include +#include +#include +#include +#include +#include +#include + +#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('0' + next % 10)); + carry = next / 10; + } + while (carry > 0) { + decimal.prepend(QLatin1Char(static_cast('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(api)), logos(ownedLogos.get()) + { + } + + explicit Impl(LogosModules* value) + : logos(value) + { + } + + std::unique_ptr ownedLogos; + LogosModules* logos = nullptr; +}; + +LogosWalletProvider::LogosWalletProvider(LogosAPI* api) + : m_impl(std::make_unique(api)) +{ +} + +LogosWalletProvider::LogosWalletProvider(LogosModules* logos) + : m_impl(std::make_unique(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( + 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( + 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; +} diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h new file mode 100644 index 00000000..d0462906 --- /dev/null +++ b/apps/shared/wallet/src/LogosWalletProvider.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#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 m_impl; + WalletSnapshot m_snapshot; + bool m_snapshotReady = false; + bool m_connected = false; +}; diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp new file mode 100644 index 00000000..06e94bfd --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -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 WalletAccountModel::roleNames() const +{ + return { + { NameRole, "name" }, + { AddressRole, "address" }, + { BalanceRole, "balance" }, + { IsPublicRole, "isPublic" }, + }; +} + +void WalletAccountModel::replaceAccounts(const QVector& 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(); +} diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h new file mode 100644 index 00000000..c00b0d65 --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#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 roleNames() const override; + + void replaceAccounts(const QVector& accounts); + int count() const { return m_accounts.size(); } + +signals: + void countChanged(); + +private: + struct Entry { + QString name; + QString address; + QString balance; + bool isPublic = true; + }; + + QVector m_accounts; +}; diff --git a/apps/shared/wallet/src/WalletProvider.cpp b/apps/shared/wallet/src/WalletProvider.cpp new file mode 100644 index 00000000..9cb259d4 --- /dev/null +++ b/apps/shared/wallet/src/WalletProvider.cpp @@ -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"); +} diff --git a/apps/shared/wallet/src/WalletProvider.h b/apps/shared/wallet/src/WalletProvider.h new file mode 100644 index 00000000..a7814caa --- /dev/null +++ b/apps/shared/wallet/src/WalletProvider.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include + +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 accounts; + QVector 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 signingRequirements; + QVector 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; +}; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp new file mode 100644 index 00000000..33672d90 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -0,0 +1,367 @@ +#include +#include +#include +#include +#include +#include + +#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" diff --git a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h new file mode 100644 index 00000000..c1927f43 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include + +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 publicAccounts; + QHash 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; +}; diff --git a/apps/shared/wallet/tests/qml/main.cpp b/apps/shared/wallet/tests/qml/main.cpp new file mode 100644 index 00000000..42c698d3 --- /dev/null +++ b/apps/shared/wallet/tests/qml/main.cpp @@ -0,0 +1,3 @@ +#include + +QUICK_TEST_MAIN(logos_wallet_qml) diff --git a/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml b/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml new file mode 100644 index 00000000..44e57c41 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml @@ -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) + } + } +} diff --git a/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml new file mode 100644 index 00000000..898a2450 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml @@ -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 } +} diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml new file mode 100644 index 00000000..157a0bcd --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -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)) + } + } +} diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h new file mode 100644 index 00000000..03b12ada --- /dev/null +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -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; } +}; From ae52613279832140965f33c4f92db65b4b608527 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 15 Jul 2026 11:55:53 -0300 Subject: [PATCH 02/14] refactor(amm-ui): use shared wallet modules Replace AMM-local wallet access, account state, controls, and confirmation-dialog mechanics with the reusable wallet targets. Keep AMM-specific transaction summaries and actions in the AMM UI.\n\nRefs #227 --- apps/amm/CMakeLists.txt | 19 +- apps/amm/flake.lock | 905 +++++++++--------- apps/amm/flake.nix | 32 +- apps/amm/qml/Logos/Wallet/qmldir | 5 + apps/amm/qml/NavBar.qml | 11 +- .../liquidity/LiquidityConfirmationDialog.qml | 238 ----- .../LiquidityConfirmationSummary.qml | 91 ++ .../swap/SwapConfirmationDialog.qml | 225 ----- .../swap/SwapConfirmationSummary.qml | 88 ++ .../qml/components/wallet/AccountControl.qml | 490 ---------- .../qml/components/wallet/AccountDelegate.qml | 84 -- .../components/wallet/CreateAccountDialog.qml | 102 -- .../components/wallet/CreateWalletDialog.qml | 201 ---- .../qml/components/wallet/LogosCopyButton.qml | 39 - .../components/wallet/WalletIconButton.qml | 25 - .../qml/components/wallet/icons/account.svg | 1 - apps/amm/qml/components/wallet/icons/back.svg | 1 - .../qml/components/wallet/icons/checkmark.svg | 1 - apps/amm/qml/components/wallet/icons/copy.svg | 1 - .../amm/qml/components/wallet/icons/power.svg | 1 - .../qml/components/wallet/icons/settings.svg | 1 - apps/amm/qml/pages/LiquidityPage.qml | 15 +- apps/amm/qml/pages/SwapPage.qml | 42 +- apps/amm/src/AccountModel.cpp | 79 -- apps/amm/src/AccountModel.h | 47 - apps/amm/src/AmmUiBackend.cpp | 379 +++----- apps/amm/src/AmmUiBackend.h | 39 +- apps/amm/src/AmmUiBackend.rep | 6 - 28 files changed, 854 insertions(+), 2314 deletions(-) create mode 100644 apps/amm/qml/Logos/Wallet/qmldir delete mode 100644 apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml create mode 100644 apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml delete mode 100644 apps/amm/qml/components/swap/SwapConfirmationDialog.qml create mode 100644 apps/amm/qml/components/swap/SwapConfirmationSummary.qml delete mode 100644 apps/amm/qml/components/wallet/AccountControl.qml delete mode 100644 apps/amm/qml/components/wallet/AccountDelegate.qml delete mode 100644 apps/amm/qml/components/wallet/CreateAccountDialog.qml delete mode 100644 apps/amm/qml/components/wallet/CreateWalletDialog.qml delete mode 100644 apps/amm/qml/components/wallet/LogosCopyButton.qml delete mode 100644 apps/amm/qml/components/wallet/WalletIconButton.qml delete mode 100644 apps/amm/qml/components/wallet/icons/account.svg delete mode 100644 apps/amm/qml/components/wallet/icons/back.svg delete mode 100644 apps/amm/qml/components/wallet/icons/checkmark.svg delete mode 100644 apps/amm/qml/components/wallet/icons/copy.svg delete mode 100644 apps/amm/qml/components/wallet/icons/power.svg delete mode 100644 apps/amm/qml/components/wallet/icons/settings.svg delete mode 100644 apps/amm/src/AccountModel.cpp delete mode 100644 apps/amm/src/AccountModel.h diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 10f4346d..38c8081c 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -1,12 +1,25 @@ -cmake_minimum_required(VERSION 3.14) +cmake_minimum_required(VERSION 3.21) project(AmmUiPlugin LANGUAGES CXX) +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2) +qt_standard_project_setup(REQUIRES 6.8) + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) else() message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.") endif() +set(LOGOS_WALLET_SOURCE_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/../shared/wallet" + CACHE PATH "Path to the shared Logos wallet module" +) +set(LOGOS_WALLET_GENERATED_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/generated_code" + CACHE PATH "Path to generated Logos SDK sources" +) +add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet") + # ui_qml module with a hand-written C++ backend (QtRO .rep view contract + # generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module. logos_module( @@ -18,12 +31,12 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp - src/AccountModel.h - src/AccountModel.cpp FIND_PACKAGES Qt6Gui Qt6Network LINK_LIBRARIES Qt6::Gui Qt6::Network + LINK_TARGETS + logos_wallet_access ) diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index 9bc7175f..e0014b39 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,6 +1,6 @@ { "nodes": { - "crane": { + "crane_2": { "locked": { "lastModified": 1780532242, "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", @@ -76,7 +76,7 @@ }, "logos-blockchain-circuits": { "inputs": { - "nixpkgs": "nixpkgs_124" + "nixpkgs": "nixpkgs_125" }, "locked": { "lastModified": 1781004244, @@ -2225,7 +2225,7 @@ }, "logos-execution-zone": { "inputs": { - "crane": "crane", + "crane": "crane_2", "logos-blockchain-circuits": "logos-blockchain-circuits", "logos-liblogos": "logos-liblogos_4", "nixpkgs": [ @@ -2238,17 +2238,17 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1782310268, - "narHash": "sha256-tIIIO+V+w/C/KLtKnLIv8O8/GoJ4PzgJSWrlQCXJ2P4=", + "lastModified": 1782428741, + "narHash": "sha256-ltLcysXUdVUXAe25Tl8x7e7ZsTzj1sHlyS3glp97TAo=", "owner": "logos-blockchain", - "repo": "lssa", - "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a", + "repo": "logos-execution-zone", + "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", "type": "github" }, "original": { "owner": "logos-blockchain", - "repo": "lssa", - "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a", + "repo": "logos-execution-zone", + "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", "type": "github" } }, @@ -4763,7 +4763,7 @@ }, "logos-nix": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1774455309, @@ -4781,7 +4781,7 @@ }, "logos-nix_10": { "inputs": { - "nixpkgs": "nixpkgs_10" + "nixpkgs": "nixpkgs_11" }, "locked": { "lastModified": 1774455309, @@ -4799,7 +4799,7 @@ }, "logos-nix_100": { "inputs": { - "nixpkgs": "nixpkgs_100" + "nixpkgs": "nixpkgs_101" }, "locked": { "lastModified": 1773955630, @@ -4817,7 +4817,7 @@ }, "logos-nix_101": { "inputs": { - "nixpkgs": "nixpkgs_101" + "nixpkgs": "nixpkgs_102" }, "locked": { "lastModified": 1773955630, @@ -4835,7 +4835,7 @@ }, "logos-nix_102": { "inputs": { - "nixpkgs": "nixpkgs_102" + "nixpkgs": "nixpkgs_103" }, "locked": { "lastModified": 1773955630, @@ -4853,7 +4853,7 @@ }, "logos-nix_103": { "inputs": { - "nixpkgs": "nixpkgs_103" + "nixpkgs": "nixpkgs_104" }, "locked": { "lastModified": 1773955630, @@ -4871,7 +4871,7 @@ }, "logos-nix_104": { "inputs": { - "nixpkgs": "nixpkgs_104" + "nixpkgs": "nixpkgs_105" }, "locked": { "lastModified": 1773955630, @@ -4889,7 +4889,7 @@ }, "logos-nix_105": { "inputs": { - "nixpkgs": "nixpkgs_105" + "nixpkgs": "nixpkgs_106" }, "locked": { "lastModified": 1774455309, @@ -4907,7 +4907,7 @@ }, "logos-nix_106": { "inputs": { - "nixpkgs": "nixpkgs_106" + "nixpkgs": "nixpkgs_107" }, "locked": { "lastModified": 1773955630, @@ -4925,7 +4925,7 @@ }, "logos-nix_107": { "inputs": { - "nixpkgs": "nixpkgs_107" + "nixpkgs": "nixpkgs_108" }, "locked": { "lastModified": 1774455309, @@ -4943,7 +4943,7 @@ }, "logos-nix_108": { "inputs": { - "nixpkgs": "nixpkgs_108" + "nixpkgs": "nixpkgs_109" }, "locked": { "lastModified": 1774455309, @@ -4961,7 +4961,7 @@ }, "logos-nix_109": { "inputs": { - "nixpkgs": "nixpkgs_109" + "nixpkgs": "nixpkgs_110" }, "locked": { "lastModified": 1773955630, @@ -4979,7 +4979,7 @@ }, "logos-nix_11": { "inputs": { - "nixpkgs": "nixpkgs_11" + "nixpkgs": "nixpkgs_12" }, "locked": { "lastModified": 1773955630, @@ -4997,7 +4997,7 @@ }, "logos-nix_110": { "inputs": { - "nixpkgs": "nixpkgs_110" + "nixpkgs": "nixpkgs_111" }, "locked": { "lastModified": 1773955630, @@ -5015,7 +5015,7 @@ }, "logos-nix_111": { "inputs": { - "nixpkgs": "nixpkgs_111" + "nixpkgs": "nixpkgs_112" }, "locked": { "lastModified": 1774455309, @@ -5033,7 +5033,7 @@ }, "logos-nix_112": { "inputs": { - "nixpkgs": "nixpkgs_112" + "nixpkgs": "nixpkgs_113" }, "locked": { "lastModified": 1774455309, @@ -5051,7 +5051,7 @@ }, "logos-nix_113": { "inputs": { - "nixpkgs": "nixpkgs_113" + "nixpkgs": "nixpkgs_114" }, "locked": { "lastModified": 1773955630, @@ -5069,7 +5069,7 @@ }, "logos-nix_114": { "inputs": { - "nixpkgs": "nixpkgs_114" + "nixpkgs": "nixpkgs_115" }, "locked": { "lastModified": 1773955630, @@ -5087,7 +5087,7 @@ }, "logos-nix_115": { "inputs": { - "nixpkgs": "nixpkgs_115" + "nixpkgs": "nixpkgs_116" }, "locked": { "lastModified": 1774455309, @@ -5105,7 +5105,7 @@ }, "logos-nix_116": { "inputs": { - "nixpkgs": "nixpkgs_116" + "nixpkgs": "nixpkgs_117" }, "locked": { "lastModified": 1774455309, @@ -5123,7 +5123,7 @@ }, "logos-nix_117": { "inputs": { - "nixpkgs": "nixpkgs_117" + "nixpkgs": "nixpkgs_118" }, "locked": { "lastModified": 1773955630, @@ -5141,7 +5141,7 @@ }, "logos-nix_118": { "inputs": { - "nixpkgs": "nixpkgs_118" + "nixpkgs": "nixpkgs_119" }, "locked": { "lastModified": 1773955630, @@ -5159,7 +5159,7 @@ }, "logos-nix_119": { "inputs": { - "nixpkgs": "nixpkgs_119" + "nixpkgs": "nixpkgs_120" }, "locked": { "lastModified": 1773955630, @@ -5177,7 +5177,7 @@ }, "logos-nix_12": { "inputs": { - "nixpkgs": "nixpkgs_12" + "nixpkgs": "nixpkgs_13" }, "locked": { "lastModified": 1774455309, @@ -5195,7 +5195,7 @@ }, "logos-nix_120": { "inputs": { - "nixpkgs": "nixpkgs_120" + "nixpkgs": "nixpkgs_121" }, "locked": { "lastModified": 1773955630, @@ -5213,7 +5213,7 @@ }, "logos-nix_121": { "inputs": { - "nixpkgs": "nixpkgs_121" + "nixpkgs": "nixpkgs_122" }, "locked": { "lastModified": 1774455309, @@ -5231,7 +5231,7 @@ }, "logos-nix_122": { "inputs": { - "nixpkgs": "nixpkgs_122" + "nixpkgs": "nixpkgs_123" }, "locked": { "lastModified": 1773955630, @@ -5249,7 +5249,7 @@ }, "logos-nix_123": { "inputs": { - "nixpkgs": "nixpkgs_123" + "nixpkgs": "nixpkgs_124" }, "locked": { "lastModified": 1773955630, @@ -5267,7 +5267,7 @@ }, "logos-nix_124": { "inputs": { - "nixpkgs": "nixpkgs_125" + "nixpkgs": "nixpkgs_126" }, "locked": { "lastModified": 1774455309, @@ -5285,7 +5285,7 @@ }, "logos-nix_125": { "inputs": { - "nixpkgs": "nixpkgs_126" + "nixpkgs": "nixpkgs_127" }, "locked": { "lastModified": 1774455309, @@ -5303,7 +5303,7 @@ }, "logos-nix_126": { "inputs": { - "nixpkgs": "nixpkgs_127" + "nixpkgs": "nixpkgs_128" }, "locked": { "lastModified": 1774455309, @@ -5321,7 +5321,7 @@ }, "logos-nix_127": { "inputs": { - "nixpkgs": "nixpkgs_128" + "nixpkgs": "nixpkgs_129" }, "locked": { "lastModified": 1774455309, @@ -5339,7 +5339,7 @@ }, "logos-nix_128": { "inputs": { - "nixpkgs": "nixpkgs_129" + "nixpkgs": "nixpkgs_130" }, "locked": { "lastModified": 1773955630, @@ -5357,7 +5357,7 @@ }, "logos-nix_129": { "inputs": { - "nixpkgs": "nixpkgs_130" + "nixpkgs": "nixpkgs_131" }, "locked": { "lastModified": 1774455309, @@ -5375,7 +5375,7 @@ }, "logos-nix_13": { "inputs": { - "nixpkgs": "nixpkgs_13" + "nixpkgs": "nixpkgs_14" }, "locked": { "lastModified": 1773955630, @@ -5393,7 +5393,7 @@ }, "logos-nix_130": { "inputs": { - "nixpkgs": "nixpkgs_131" + "nixpkgs": "nixpkgs_132" }, "locked": { "lastModified": 1774455309, @@ -5411,7 +5411,7 @@ }, "logos-nix_131": { "inputs": { - "nixpkgs": "nixpkgs_132" + "nixpkgs": "nixpkgs_133" }, "locked": { "lastModified": 1774455309, @@ -5429,7 +5429,7 @@ }, "logos-nix_132": { "inputs": { - "nixpkgs": "nixpkgs_133" + "nixpkgs": "nixpkgs_134" }, "locked": { "lastModified": 1774455309, @@ -5447,7 +5447,7 @@ }, "logos-nix_133": { "inputs": { - "nixpkgs": "nixpkgs_134" + "nixpkgs": "nixpkgs_135" }, "locked": { "lastModified": 1774455309, @@ -5465,7 +5465,7 @@ }, "logos-nix_134": { "inputs": { - "nixpkgs": "nixpkgs_135" + "nixpkgs": "nixpkgs_136" }, "locked": { "lastModified": 1774455309, @@ -5483,7 +5483,7 @@ }, "logos-nix_135": { "inputs": { - "nixpkgs": "nixpkgs_136" + "nixpkgs": "nixpkgs_137" }, "locked": { "lastModified": 1773955630, @@ -5501,7 +5501,7 @@ }, "logos-nix_136": { "inputs": { - "nixpkgs": "nixpkgs_137" + "nixpkgs": "nixpkgs_138" }, "locked": { "lastModified": 1774455309, @@ -5519,7 +5519,7 @@ }, "logos-nix_137": { "inputs": { - "nixpkgs": "nixpkgs_138" + "nixpkgs": "nixpkgs_139" }, "locked": { "lastModified": 1773955630, @@ -5537,7 +5537,7 @@ }, "logos-nix_138": { "inputs": { - "nixpkgs": "nixpkgs_139" + "nixpkgs": "nixpkgs_140" }, "locked": { "lastModified": 1774455309, @@ -5555,7 +5555,7 @@ }, "logos-nix_139": { "inputs": { - "nixpkgs": "nixpkgs_140" + "nixpkgs": "nixpkgs_141" }, "locked": { "lastModified": 1773955630, @@ -5573,7 +5573,7 @@ }, "logos-nix_14": { "inputs": { - "nixpkgs": "nixpkgs_14" + "nixpkgs": "nixpkgs_15" }, "locked": { "lastModified": 1774455309, @@ -5591,7 +5591,7 @@ }, "logos-nix_140": { "inputs": { - "nixpkgs": "nixpkgs_141" + "nixpkgs": "nixpkgs_142" }, "locked": { "lastModified": 1774455309, @@ -5609,7 +5609,7 @@ }, "logos-nix_141": { "inputs": { - "nixpkgs": "nixpkgs_142" + "nixpkgs": "nixpkgs_143" }, "locked": { "lastModified": 1774455309, @@ -5627,7 +5627,7 @@ }, "logos-nix_142": { "inputs": { - "nixpkgs": "nixpkgs_143" + "nixpkgs": "nixpkgs_144" }, "locked": { "lastModified": 1773955630, @@ -5645,7 +5645,7 @@ }, "logos-nix_143": { "inputs": { - "nixpkgs": "nixpkgs_144" + "nixpkgs": "nixpkgs_145" }, "locked": { "lastModified": 1774455309, @@ -5663,7 +5663,7 @@ }, "logos-nix_144": { "inputs": { - "nixpkgs": "nixpkgs_145" + "nixpkgs": "nixpkgs_146" }, "locked": { "lastModified": 1773955630, @@ -5681,7 +5681,7 @@ }, "logos-nix_145": { "inputs": { - "nixpkgs": "nixpkgs_146" + "nixpkgs": "nixpkgs_147" }, "locked": { "lastModified": 1774455309, @@ -5699,7 +5699,7 @@ }, "logos-nix_146": { "inputs": { - "nixpkgs": "nixpkgs_147" + "nixpkgs": "nixpkgs_148" }, "locked": { "lastModified": 1773955630, @@ -5717,7 +5717,7 @@ }, "logos-nix_147": { "inputs": { - "nixpkgs": "nixpkgs_148" + "nixpkgs": "nixpkgs_149" }, "locked": { "lastModified": 1774455309, @@ -5735,7 +5735,7 @@ }, "logos-nix_148": { "inputs": { - "nixpkgs": "nixpkgs_149" + "nixpkgs": "nixpkgs_150" }, "locked": { "lastModified": 1773955630, @@ -5753,7 +5753,7 @@ }, "logos-nix_149": { "inputs": { - "nixpkgs": "nixpkgs_150" + "nixpkgs": "nixpkgs_151" }, "locked": { "lastModified": 1773955630, @@ -5771,7 +5771,7 @@ }, "logos-nix_15": { "inputs": { - "nixpkgs": "nixpkgs_15" + "nixpkgs": "nixpkgs_16" }, "locked": { "lastModified": 1773955630, @@ -5789,7 +5789,7 @@ }, "logos-nix_150": { "inputs": { - "nixpkgs": "nixpkgs_151" + "nixpkgs": "nixpkgs_152" }, "locked": { "lastModified": 1774455309, @@ -5807,7 +5807,7 @@ }, "logos-nix_151": { "inputs": { - "nixpkgs": "nixpkgs_152" + "nixpkgs": "nixpkgs_153" }, "locked": { "lastModified": 1773955630, @@ -5825,7 +5825,7 @@ }, "logos-nix_152": { "inputs": { - "nixpkgs": "nixpkgs_153" + "nixpkgs": "nixpkgs_154" }, "locked": { "lastModified": 1773955630, @@ -5843,7 +5843,7 @@ }, "logos-nix_153": { "inputs": { - "nixpkgs": "nixpkgs_154" + "nixpkgs": "nixpkgs_155" }, "locked": { "lastModified": 1773955630, @@ -5861,7 +5861,7 @@ }, "logos-nix_154": { "inputs": { - "nixpkgs": "nixpkgs_155" + "nixpkgs": "nixpkgs_156" }, "locked": { "lastModified": 1773955630, @@ -5879,7 +5879,7 @@ }, "logos-nix_155": { "inputs": { - "nixpkgs": "nixpkgs_156" + "nixpkgs": "nixpkgs_157" }, "locked": { "lastModified": 1774455309, @@ -5897,7 +5897,7 @@ }, "logos-nix_156": { "inputs": { - "nixpkgs": "nixpkgs_157" + "nixpkgs": "nixpkgs_158" }, "locked": { "lastModified": 1773955630, @@ -5915,7 +5915,7 @@ }, "logos-nix_157": { "inputs": { - "nixpkgs": "nixpkgs_158" + "nixpkgs": "nixpkgs_159" }, "locked": { "lastModified": 1774455309, @@ -5933,7 +5933,7 @@ }, "logos-nix_158": { "inputs": { - "nixpkgs": "nixpkgs_159" + "nixpkgs": "nixpkgs_160" }, "locked": { "lastModified": 1774455309, @@ -5951,7 +5951,7 @@ }, "logos-nix_159": { "inputs": { - "nixpkgs": "nixpkgs_160" + "nixpkgs": "nixpkgs_161" }, "locked": { "lastModified": 1773955630, @@ -5969,7 +5969,7 @@ }, "logos-nix_16": { "inputs": { - "nixpkgs": "nixpkgs_16" + "nixpkgs": "nixpkgs_17" }, "locked": { "lastModified": 1774455309, @@ -5987,7 +5987,7 @@ }, "logos-nix_160": { "inputs": { - "nixpkgs": "nixpkgs_161" + "nixpkgs": "nixpkgs_162" }, "locked": { "lastModified": 1773955630, @@ -6005,7 +6005,7 @@ }, "logos-nix_161": { "inputs": { - "nixpkgs": "nixpkgs_162" + "nixpkgs": "nixpkgs_163" }, "locked": { "lastModified": 1773955630, @@ -6023,7 +6023,7 @@ }, "logos-nix_162": { "inputs": { - "nixpkgs": "nixpkgs_163" + "nixpkgs": "nixpkgs_164" }, "locked": { "lastModified": 1773955630, @@ -6041,7 +6041,7 @@ }, "logos-nix_163": { "inputs": { - "nixpkgs": "nixpkgs_164" + "nixpkgs": "nixpkgs_165" }, "locked": { "lastModified": 1773955630, @@ -6059,7 +6059,7 @@ }, "logos-nix_164": { "inputs": { - "nixpkgs": "nixpkgs_165" + "nixpkgs": "nixpkgs_166" }, "locked": { "lastModified": 1774455309, @@ -6077,7 +6077,7 @@ }, "logos-nix_165": { "inputs": { - "nixpkgs": "nixpkgs_166" + "nixpkgs": "nixpkgs_167" }, "locked": { "lastModified": 1773955630, @@ -6095,7 +6095,7 @@ }, "logos-nix_166": { "inputs": { - "nixpkgs": "nixpkgs_167" + "nixpkgs": "nixpkgs_168" }, "locked": { "lastModified": 1774455309, @@ -6113,7 +6113,7 @@ }, "logos-nix_167": { "inputs": { - "nixpkgs": "nixpkgs_168" + "nixpkgs": "nixpkgs_169" }, "locked": { "lastModified": 1774455309, @@ -6131,7 +6131,7 @@ }, "logos-nix_168": { "inputs": { - "nixpkgs": "nixpkgs_169" + "nixpkgs": "nixpkgs_170" }, "locked": { "lastModified": 1773955630, @@ -6149,7 +6149,7 @@ }, "logos-nix_169": { "inputs": { - "nixpkgs": "nixpkgs_170" + "nixpkgs": "nixpkgs_171" }, "locked": { "lastModified": 1773955630, @@ -6167,7 +6167,7 @@ }, "logos-nix_17": { "inputs": { - "nixpkgs": "nixpkgs_17" + "nixpkgs": "nixpkgs_18" }, "locked": { "lastModified": 1773955630, @@ -6185,7 +6185,7 @@ }, "logos-nix_170": { "inputs": { - "nixpkgs": "nixpkgs_171" + "nixpkgs": "nixpkgs_172" }, "locked": { "lastModified": 1774455309, @@ -6203,7 +6203,7 @@ }, "logos-nix_171": { "inputs": { - "nixpkgs": "nixpkgs_172" + "nixpkgs": "nixpkgs_173" }, "locked": { "lastModified": 1774455309, @@ -6221,7 +6221,7 @@ }, "logos-nix_172": { "inputs": { - "nixpkgs": "nixpkgs_173" + "nixpkgs": "nixpkgs_174" }, "locked": { "lastModified": 1773955630, @@ -6239,7 +6239,7 @@ }, "logos-nix_173": { "inputs": { - "nixpkgs": "nixpkgs_174" + "nixpkgs": "nixpkgs_175" }, "locked": { "lastModified": 1773955630, @@ -6257,7 +6257,7 @@ }, "logos-nix_174": { "inputs": { - "nixpkgs": "nixpkgs_175" + "nixpkgs": "nixpkgs_176" }, "locked": { "lastModified": 1774455309, @@ -6275,7 +6275,7 @@ }, "logos-nix_175": { "inputs": { - "nixpkgs": "nixpkgs_176" + "nixpkgs": "nixpkgs_177" }, "locked": { "lastModified": 1774455309, @@ -6293,7 +6293,7 @@ }, "logos-nix_176": { "inputs": { - "nixpkgs": "nixpkgs_177" + "nixpkgs": "nixpkgs_178" }, "locked": { "lastModified": 1773955630, @@ -6311,7 +6311,7 @@ }, "logos-nix_177": { "inputs": { - "nixpkgs": "nixpkgs_178" + "nixpkgs": "nixpkgs_179" }, "locked": { "lastModified": 1773955630, @@ -6329,7 +6329,7 @@ }, "logos-nix_178": { "inputs": { - "nixpkgs": "nixpkgs_179" + "nixpkgs": "nixpkgs_180" }, "locked": { "lastModified": 1773955630, @@ -6347,7 +6347,7 @@ }, "logos-nix_179": { "inputs": { - "nixpkgs": "nixpkgs_180" + "nixpkgs": "nixpkgs_181" }, "locked": { "lastModified": 1773955630, @@ -6365,7 +6365,7 @@ }, "logos-nix_18": { "inputs": { - "nixpkgs": "nixpkgs_18" + "nixpkgs": "nixpkgs_19" }, "locked": { "lastModified": 1773955630, @@ -6383,7 +6383,7 @@ }, "logos-nix_180": { "inputs": { - "nixpkgs": "nixpkgs_181" + "nixpkgs": "nixpkgs_182" }, "locked": { "lastModified": 1774455309, @@ -6401,7 +6401,7 @@ }, "logos-nix_181": { "inputs": { - "nixpkgs": "nixpkgs_182" + "nixpkgs": "nixpkgs_183" }, "locked": { "lastModified": 1773955630, @@ -6419,7 +6419,7 @@ }, "logos-nix_182": { "inputs": { - "nixpkgs": "nixpkgs_183" + "nixpkgs": "nixpkgs_184" }, "locked": { "lastModified": 1773955630, @@ -6437,7 +6437,7 @@ }, "logos-nix_183": { "inputs": { - "nixpkgs": "nixpkgs_184" + "nixpkgs": "nixpkgs_185" }, "locked": { "lastModified": 1774455309, @@ -6455,7 +6455,7 @@ }, "logos-nix_184": { "inputs": { - "nixpkgs": "nixpkgs_185" + "nixpkgs": "nixpkgs_186" }, "locked": { "lastModified": 1773955630, @@ -6473,7 +6473,7 @@ }, "logos-nix_185": { "inputs": { - "nixpkgs": "nixpkgs_186" + "nixpkgs": "nixpkgs_187" }, "locked": { "lastModified": 1774455309, @@ -6491,7 +6491,7 @@ }, "logos-nix_186": { "inputs": { - "nixpkgs": "nixpkgs_187" + "nixpkgs": "nixpkgs_188" }, "locked": { "lastModified": 1773955630, @@ -6509,7 +6509,7 @@ }, "logos-nix_187": { "inputs": { - "nixpkgs": "nixpkgs_188" + "nixpkgs": "nixpkgs_189" }, "locked": { "lastModified": 1774455309, @@ -6527,7 +6527,7 @@ }, "logos-nix_188": { "inputs": { - "nixpkgs": "nixpkgs_189" + "nixpkgs": "nixpkgs_190" }, "locked": { "lastModified": 1773955630, @@ -6545,7 +6545,7 @@ }, "logos-nix_189": { "inputs": { - "nixpkgs": "nixpkgs_190" + "nixpkgs": "nixpkgs_191" }, "locked": { "lastModified": 1774455309, @@ -6563,7 +6563,7 @@ }, "logos-nix_19": { "inputs": { - "nixpkgs": "nixpkgs_19" + "nixpkgs": "nixpkgs_20" }, "locked": { "lastModified": 1774455309, @@ -6581,7 +6581,7 @@ }, "logos-nix_190": { "inputs": { - "nixpkgs": "nixpkgs_191" + "nixpkgs": "nixpkgs_192" }, "locked": { "lastModified": 1773955630, @@ -6599,7 +6599,7 @@ }, "logos-nix_191": { "inputs": { - "nixpkgs": "nixpkgs_192" + "nixpkgs": "nixpkgs_193" }, "locked": { "lastModified": 1774455309, @@ -6617,7 +6617,7 @@ }, "logos-nix_192": { "inputs": { - "nixpkgs": "nixpkgs_193" + "nixpkgs": "nixpkgs_194" }, "locked": { "lastModified": 1773955630, @@ -6635,7 +6635,7 @@ }, "logos-nix_193": { "inputs": { - "nixpkgs": "nixpkgs_194" + "nixpkgs": "nixpkgs_195" }, "locked": { "lastModified": 1773955630, @@ -6653,7 +6653,7 @@ }, "logos-nix_194": { "inputs": { - "nixpkgs": "nixpkgs_195" + "nixpkgs": "nixpkgs_196" }, "locked": { "lastModified": 1774455309, @@ -6671,7 +6671,7 @@ }, "logos-nix_195": { "inputs": { - "nixpkgs": "nixpkgs_196" + "nixpkgs": "nixpkgs_197" }, "locked": { "lastModified": 1773955630, @@ -6689,7 +6689,7 @@ }, "logos-nix_196": { "inputs": { - "nixpkgs": "nixpkgs_197" + "nixpkgs": "nixpkgs_198" }, "locked": { "lastModified": 1773955630, @@ -6707,7 +6707,7 @@ }, "logos-nix_197": { "inputs": { - "nixpkgs": "nixpkgs_198" + "nixpkgs": "nixpkgs_199" }, "locked": { "lastModified": 1773955630, @@ -6725,7 +6725,7 @@ }, "logos-nix_198": { "inputs": { - "nixpkgs": "nixpkgs_199" + "nixpkgs": "nixpkgs_200" }, "locked": { "lastModified": 1773955630, @@ -6743,7 +6743,7 @@ }, "logos-nix_199": { "inputs": { - "nixpkgs": "nixpkgs_200" + "nixpkgs": "nixpkgs_201" }, "locked": { "lastModified": 1774455309, @@ -6761,7 +6761,7 @@ }, "logos-nix_2": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_3" }, "locked": { "lastModified": 1773955630, @@ -6779,7 +6779,7 @@ }, "logos-nix_20": { "inputs": { - "nixpkgs": "nixpkgs_20" + "nixpkgs": "nixpkgs_21" }, "locked": { "lastModified": 1773955630, @@ -6797,7 +6797,7 @@ }, "logos-nix_200": { "inputs": { - "nixpkgs": "nixpkgs_201" + "nixpkgs": "nixpkgs_202" }, "locked": { "lastModified": 1773955630, @@ -6815,7 +6815,7 @@ }, "logos-nix_201": { "inputs": { - "nixpkgs": "nixpkgs_202" + "nixpkgs": "nixpkgs_203" }, "locked": { "lastModified": 1774455309, @@ -6833,7 +6833,7 @@ }, "logos-nix_202": { "inputs": { - "nixpkgs": "nixpkgs_203" + "nixpkgs": "nixpkgs_204" }, "locked": { "lastModified": 1774455309, @@ -6851,7 +6851,7 @@ }, "logos-nix_203": { "inputs": { - "nixpkgs": "nixpkgs_204" + "nixpkgs": "nixpkgs_205" }, "locked": { "lastModified": 1773955630, @@ -6869,7 +6869,7 @@ }, "logos-nix_204": { "inputs": { - "nixpkgs": "nixpkgs_205" + "nixpkgs": "nixpkgs_206" }, "locked": { "lastModified": 1773955630, @@ -6887,7 +6887,7 @@ }, "logos-nix_205": { "inputs": { - "nixpkgs": "nixpkgs_206" + "nixpkgs": "nixpkgs_207" }, "locked": { "lastModified": 1773955630, @@ -6905,7 +6905,7 @@ }, "logos-nix_206": { "inputs": { - "nixpkgs": "nixpkgs_207" + "nixpkgs": "nixpkgs_208" }, "locked": { "lastModified": 1773955630, @@ -6923,7 +6923,7 @@ }, "logos-nix_207": { "inputs": { - "nixpkgs": "nixpkgs_208" + "nixpkgs": "nixpkgs_209" }, "locked": { "lastModified": 1773955630, @@ -6941,7 +6941,7 @@ }, "logos-nix_208": { "inputs": { - "nixpkgs": "nixpkgs_209" + "nixpkgs": "nixpkgs_210" }, "locked": { "lastModified": 1774455309, @@ -6959,7 +6959,7 @@ }, "logos-nix_209": { "inputs": { - "nixpkgs": "nixpkgs_210" + "nixpkgs": "nixpkgs_211" }, "locked": { "lastModified": 1773955630, @@ -6977,7 +6977,7 @@ }, "logos-nix_21": { "inputs": { - "nixpkgs": "nixpkgs_21" + "nixpkgs": "nixpkgs_22" }, "locked": { "lastModified": 1773955630, @@ -6995,7 +6995,7 @@ }, "logos-nix_210": { "inputs": { - "nixpkgs": "nixpkgs_211" + "nixpkgs": "nixpkgs_212" }, "locked": { "lastModified": 1774455309, @@ -7013,7 +7013,7 @@ }, "logos-nix_211": { "inputs": { - "nixpkgs": "nixpkgs_212" + "nixpkgs": "nixpkgs_213" }, "locked": { "lastModified": 1774455309, @@ -7031,7 +7031,7 @@ }, "logos-nix_212": { "inputs": { - "nixpkgs": "nixpkgs_213" + "nixpkgs": "nixpkgs_214" }, "locked": { "lastModified": 1773955630, @@ -7049,7 +7049,7 @@ }, "logos-nix_213": { "inputs": { - "nixpkgs": "nixpkgs_214" + "nixpkgs": "nixpkgs_215" }, "locked": { "lastModified": 1773955630, @@ -7067,7 +7067,7 @@ }, "logos-nix_214": { "inputs": { - "nixpkgs": "nixpkgs_215" + "nixpkgs": "nixpkgs_216" }, "locked": { "lastModified": 1774455309, @@ -7085,7 +7085,7 @@ }, "logos-nix_215": { "inputs": { - "nixpkgs": "nixpkgs_216" + "nixpkgs": "nixpkgs_217" }, "locked": { "lastModified": 1774455309, @@ -7103,7 +7103,7 @@ }, "logos-nix_216": { "inputs": { - "nixpkgs": "nixpkgs_217" + "nixpkgs": "nixpkgs_218" }, "locked": { "lastModified": 1773955630, @@ -7121,7 +7121,7 @@ }, "logos-nix_217": { "inputs": { - "nixpkgs": "nixpkgs_218" + "nixpkgs": "nixpkgs_219" }, "locked": { "lastModified": 1773955630, @@ -7139,7 +7139,7 @@ }, "logos-nix_218": { "inputs": { - "nixpkgs": "nixpkgs_219" + "nixpkgs": "nixpkgs_220" }, "locked": { "lastModified": 1774455309, @@ -7157,7 +7157,7 @@ }, "logos-nix_219": { "inputs": { - "nixpkgs": "nixpkgs_220" + "nixpkgs": "nixpkgs_221" }, "locked": { "lastModified": 1774455309, @@ -7175,7 +7175,7 @@ }, "logos-nix_22": { "inputs": { - "nixpkgs": "nixpkgs_22" + "nixpkgs": "nixpkgs_23" }, "locked": { "lastModified": 1773955630, @@ -7193,7 +7193,7 @@ }, "logos-nix_220": { "inputs": { - "nixpkgs": "nixpkgs_221" + "nixpkgs": "nixpkgs_222" }, "locked": { "lastModified": 1773955630, @@ -7211,7 +7211,7 @@ }, "logos-nix_221": { "inputs": { - "nixpkgs": "nixpkgs_222" + "nixpkgs": "nixpkgs_223" }, "locked": { "lastModified": 1773955630, @@ -7229,7 +7229,7 @@ }, "logos-nix_222": { "inputs": { - "nixpkgs": "nixpkgs_223" + "nixpkgs": "nixpkgs_224" }, "locked": { "lastModified": 1773955630, @@ -7247,7 +7247,7 @@ }, "logos-nix_223": { "inputs": { - "nixpkgs": "nixpkgs_224" + "nixpkgs": "nixpkgs_225" }, "locked": { "lastModified": 1773955630, @@ -7265,7 +7265,7 @@ }, "logos-nix_224": { "inputs": { - "nixpkgs": "nixpkgs_225" + "nixpkgs": "nixpkgs_226" }, "locked": { "lastModified": 1774455309, @@ -7283,7 +7283,7 @@ }, "logos-nix_225": { "inputs": { - "nixpkgs": "nixpkgs_226" + "nixpkgs": "nixpkgs_227" }, "locked": { "lastModified": 1773955630, @@ -7301,7 +7301,7 @@ }, "logos-nix_226": { "inputs": { - "nixpkgs": "nixpkgs_227" + "nixpkgs": "nixpkgs_228" }, "locked": { "lastModified": 1773955630, @@ -7319,7 +7319,7 @@ }, "logos-nix_227": { "inputs": { - "nixpkgs": "nixpkgs_228" + "nixpkgs": "nixpkgs_229" }, "locked": { "lastModified": 1774455309, @@ -7337,7 +7337,7 @@ }, "logos-nix_228": { "inputs": { - "nixpkgs": "nixpkgs_229" + "nixpkgs": "nixpkgs_230" }, "locked": { "lastModified": 1773955630, @@ -7355,7 +7355,7 @@ }, "logos-nix_229": { "inputs": { - "nixpkgs": "nixpkgs_230" + "nixpkgs": "nixpkgs_231" }, "locked": { "lastModified": 1774455309, @@ -7373,7 +7373,7 @@ }, "logos-nix_23": { "inputs": { - "nixpkgs": "nixpkgs_23" + "nixpkgs": "nixpkgs_24" }, "locked": { "lastModified": 1773955630, @@ -7391,7 +7391,7 @@ }, "logos-nix_230": { "inputs": { - "nixpkgs": "nixpkgs_231" + "nixpkgs": "nixpkgs_232" }, "locked": { "lastModified": 1774455309, @@ -7409,7 +7409,7 @@ }, "logos-nix_231": { "inputs": { - "nixpkgs": "nixpkgs_232" + "nixpkgs": "nixpkgs_233" }, "locked": { "lastModified": 1773955630, @@ -7427,7 +7427,7 @@ }, "logos-nix_232": { "inputs": { - "nixpkgs": "nixpkgs_233" + "nixpkgs": "nixpkgs_234" }, "locked": { "lastModified": 1773955630, @@ -7445,7 +7445,7 @@ }, "logos-nix_233": { "inputs": { - "nixpkgs": "nixpkgs_234" + "nixpkgs": "nixpkgs_235" }, "locked": { "lastModified": 1773955630, @@ -7463,7 +7463,7 @@ }, "logos-nix_234": { "inputs": { - "nixpkgs": "nixpkgs_235" + "nixpkgs": "nixpkgs_236" }, "locked": { "lastModified": 1773955630, @@ -7481,7 +7481,7 @@ }, "logos-nix_235": { "inputs": { - "nixpkgs": "nixpkgs_236" + "nixpkgs": "nixpkgs_237" }, "locked": { "lastModified": 1773955630, @@ -7499,7 +7499,7 @@ }, "logos-nix_236": { "inputs": { - "nixpkgs": "nixpkgs_237" + "nixpkgs": "nixpkgs_238" }, "locked": { "lastModified": 1774455309, @@ -7517,7 +7517,7 @@ }, "logos-nix_237": { "inputs": { - "nixpkgs": "nixpkgs_238" + "nixpkgs": "nixpkgs_239" }, "locked": { "lastModified": 1773955630, @@ -7535,7 +7535,7 @@ }, "logos-nix_238": { "inputs": { - "nixpkgs": "nixpkgs_239" + "nixpkgs": "nixpkgs_240" }, "locked": { "lastModified": 1774455309, @@ -7553,7 +7553,7 @@ }, "logos-nix_239": { "inputs": { - "nixpkgs": "nixpkgs_240" + "nixpkgs": "nixpkgs_241" }, "locked": { "lastModified": 1774455309, @@ -7571,7 +7571,7 @@ }, "logos-nix_24": { "inputs": { - "nixpkgs": "nixpkgs_24" + "nixpkgs": "nixpkgs_25" }, "locked": { "lastModified": 1774455309, @@ -7589,7 +7589,7 @@ }, "logos-nix_240": { "inputs": { - "nixpkgs": "nixpkgs_241" + "nixpkgs": "nixpkgs_242" }, "locked": { "lastModified": 1773955630, @@ -7607,7 +7607,7 @@ }, "logos-nix_241": { "inputs": { - "nixpkgs": "nixpkgs_242" + "nixpkgs": "nixpkgs_243" }, "locked": { "lastModified": 1773955630, @@ -7625,7 +7625,7 @@ }, "logos-nix_242": { "inputs": { - "nixpkgs": "nixpkgs_243" + "nixpkgs": "nixpkgs_244" }, "locked": { "lastModified": 1774455309, @@ -7643,7 +7643,7 @@ }, "logos-nix_243": { "inputs": { - "nixpkgs": "nixpkgs_244" + "nixpkgs": "nixpkgs_245" }, "locked": { "lastModified": 1774455309, @@ -7661,7 +7661,7 @@ }, "logos-nix_244": { "inputs": { - "nixpkgs": "nixpkgs_245" + "nixpkgs": "nixpkgs_246" }, "locked": { "lastModified": 1773955630, @@ -7679,7 +7679,7 @@ }, "logos-nix_245": { "inputs": { - "nixpkgs": "nixpkgs_246" + "nixpkgs": "nixpkgs_247" }, "locked": { "lastModified": 1773955630, @@ -7697,7 +7697,7 @@ }, "logos-nix_246": { "inputs": { - "nixpkgs": "nixpkgs_247" + "nixpkgs": "nixpkgs_248" }, "locked": { "lastModified": 1774455309, @@ -7715,7 +7715,7 @@ }, "logos-nix_247": { "inputs": { - "nixpkgs": "nixpkgs_248" + "nixpkgs": "nixpkgs_249" }, "locked": { "lastModified": 1774455309, @@ -7733,7 +7733,7 @@ }, "logos-nix_248": { "inputs": { - "nixpkgs": "nixpkgs_249" + "nixpkgs": "nixpkgs_250" }, "locked": { "lastModified": 1773955630, @@ -7751,7 +7751,7 @@ }, "logos-nix_249": { "inputs": { - "nixpkgs": "nixpkgs_250" + "nixpkgs": "nixpkgs_251" }, "locked": { "lastModified": 1773955630, @@ -7769,7 +7769,7 @@ }, "logos-nix_25": { "inputs": { - "nixpkgs": "nixpkgs_25" + "nixpkgs": "nixpkgs_26" }, "locked": { "lastModified": 1773955630, @@ -7787,7 +7787,7 @@ }, "logos-nix_250": { "inputs": { - "nixpkgs": "nixpkgs_251" + "nixpkgs": "nixpkgs_252" }, "locked": { "lastModified": 1773955630, @@ -7805,7 +7805,7 @@ }, "logos-nix_251": { "inputs": { - "nixpkgs": "nixpkgs_252" + "nixpkgs": "nixpkgs_253" }, "locked": { "lastModified": 1773955630, @@ -7823,7 +7823,7 @@ }, "logos-nix_252": { "inputs": { - "nixpkgs": "nixpkgs_253" + "nixpkgs": "nixpkgs_254" }, "locked": { "lastModified": 1774455309, @@ -7841,7 +7841,7 @@ }, "logos-nix_253": { "inputs": { - "nixpkgs": "nixpkgs_254" + "nixpkgs": "nixpkgs_255" }, "locked": { "lastModified": 1773955630, @@ -7859,7 +7859,7 @@ }, "logos-nix_254": { "inputs": { - "nixpkgs": "nixpkgs_255" + "nixpkgs": "nixpkgs_256" }, "locked": { "lastModified": 1773955630, @@ -7877,7 +7877,7 @@ }, "logos-nix_255": { "inputs": { - "nixpkgs": "nixpkgs_256" + "nixpkgs": "nixpkgs_257" }, "locked": { "lastModified": 1774455309, @@ -7895,7 +7895,7 @@ }, "logos-nix_256": { "inputs": { - "nixpkgs": "nixpkgs_257" + "nixpkgs": "nixpkgs_258" }, "locked": { "lastModified": 1774455309, @@ -7913,7 +7913,7 @@ }, "logos-nix_257": { "inputs": { - "nixpkgs": "nixpkgs_258" + "nixpkgs": "nixpkgs_259" }, "locked": { "lastModified": 1773955630, @@ -7931,7 +7931,7 @@ }, "logos-nix_258": { "inputs": { - "nixpkgs": "nixpkgs_259" + "nixpkgs": "nixpkgs_260" }, "locked": { "lastModified": 1774455309, @@ -7949,7 +7949,7 @@ }, "logos-nix_259": { "inputs": { - "nixpkgs": "nixpkgs_260" + "nixpkgs": "nixpkgs_261" }, "locked": { "lastModified": 1774455309, @@ -7967,7 +7967,7 @@ }, "logos-nix_26": { "inputs": { - "nixpkgs": "nixpkgs_26" + "nixpkgs": "nixpkgs_27" }, "locked": { "lastModified": 1774455309, @@ -7985,7 +7985,7 @@ }, "logos-nix_260": { "inputs": { - "nixpkgs": "nixpkgs_261" + "nixpkgs": "nixpkgs_262" }, "locked": { "lastModified": 1774455309, @@ -8003,7 +8003,7 @@ }, "logos-nix_261": { "inputs": { - "nixpkgs": "nixpkgs_262" + "nixpkgs": "nixpkgs_263" }, "locked": { "lastModified": 1774455309, @@ -8021,7 +8021,7 @@ }, "logos-nix_262": { "inputs": { - "nixpkgs": "nixpkgs_263" + "nixpkgs": "nixpkgs_264" }, "locked": { "lastModified": 1773955630, @@ -8039,7 +8039,7 @@ }, "logos-nix_263": { "inputs": { - "nixpkgs": "nixpkgs_264" + "nixpkgs": "nixpkgs_265" }, "locked": { "lastModified": 1773955630, @@ -8057,7 +8057,7 @@ }, "logos-nix_264": { "inputs": { - "nixpkgs": "nixpkgs_265" + "nixpkgs": "nixpkgs_266" }, "locked": { "lastModified": 1773955630, @@ -8075,7 +8075,7 @@ }, "logos-nix_265": { "inputs": { - "nixpkgs": "nixpkgs_266" + "nixpkgs": "nixpkgs_267" }, "locked": { "lastModified": 1773955630, @@ -8093,7 +8093,7 @@ }, "logos-nix_266": { "inputs": { - "nixpkgs": "nixpkgs_267" + "nixpkgs": "nixpkgs_268" }, "locked": { "lastModified": 1774455309, @@ -8111,7 +8111,7 @@ }, "logos-nix_267": { "inputs": { - "nixpkgs": "nixpkgs_268" + "nixpkgs": "nixpkgs_269" }, "locked": { "lastModified": 1774455309, @@ -8129,7 +8129,7 @@ }, "logos-nix_268": { "inputs": { - "nixpkgs": "nixpkgs_269" + "nixpkgs": "nixpkgs_270" }, "locked": { "lastModified": 1773955630, @@ -8147,7 +8147,7 @@ }, "logos-nix_269": { "inputs": { - "nixpkgs": "nixpkgs_271" + "nixpkgs": "nixpkgs_272" }, "locked": { "lastModified": 1774455309, @@ -8165,7 +8165,7 @@ }, "logos-nix_27": { "inputs": { - "nixpkgs": "nixpkgs_27" + "nixpkgs": "nixpkgs_28" }, "locked": { "lastModified": 1774455309, @@ -8183,7 +8183,7 @@ }, "logos-nix_270": { "inputs": { - "nixpkgs": "nixpkgs_272" + "nixpkgs": "nixpkgs_273" }, "locked": { "lastModified": 1773955630, @@ -8201,7 +8201,7 @@ }, "logos-nix_271": { "inputs": { - "nixpkgs": "nixpkgs_273" + "nixpkgs": "nixpkgs_274" }, "locked": { "lastModified": 1774455309, @@ -8219,7 +8219,7 @@ }, "logos-nix_272": { "inputs": { - "nixpkgs": "nixpkgs_274" + "nixpkgs": "nixpkgs_275" }, "locked": { "lastModified": 1773955630, @@ -8237,7 +8237,7 @@ }, "logos-nix_273": { "inputs": { - "nixpkgs": "nixpkgs_275" + "nixpkgs": "nixpkgs_276" }, "locked": { "lastModified": 1774455309, @@ -8255,7 +8255,7 @@ }, "logos-nix_274": { "inputs": { - "nixpkgs": "nixpkgs_276" + "nixpkgs": "nixpkgs_277" }, "locked": { "lastModified": 1773955630, @@ -8273,7 +8273,7 @@ }, "logos-nix_275": { "inputs": { - "nixpkgs": "nixpkgs_277" + "nixpkgs": "nixpkgs_278" }, "locked": { "lastModified": 1774455309, @@ -8291,7 +8291,7 @@ }, "logos-nix_276": { "inputs": { - "nixpkgs": "nixpkgs_278" + "nixpkgs": "nixpkgs_279" }, "locked": { "lastModified": 1774455309, @@ -8309,7 +8309,7 @@ }, "logos-nix_277": { "inputs": { - "nixpkgs": "nixpkgs_279" + "nixpkgs": "nixpkgs_280" }, "locked": { "lastModified": 1774455309, @@ -8327,7 +8327,7 @@ }, "logos-nix_278": { "inputs": { - "nixpkgs": "nixpkgs_280" + "nixpkgs": "nixpkgs_281" }, "locked": { "lastModified": 1774455309, @@ -8345,7 +8345,7 @@ }, "logos-nix_279": { "inputs": { - "nixpkgs": "nixpkgs_281" + "nixpkgs": "nixpkgs_282" }, "locked": { "lastModified": 1773955630, @@ -8363,7 +8363,7 @@ }, "logos-nix_28": { "inputs": { - "nixpkgs": "nixpkgs_28" + "nixpkgs": "nixpkgs_29" }, "locked": { "lastModified": 1773955630, @@ -8381,7 +8381,7 @@ }, "logos-nix_280": { "inputs": { - "nixpkgs": "nixpkgs_282" + "nixpkgs": "nixpkgs_283" }, "locked": { "lastModified": 1774455309, @@ -8399,7 +8399,7 @@ }, "logos-nix_281": { "inputs": { - "nixpkgs": "nixpkgs_283" + "nixpkgs": "nixpkgs_284" }, "locked": { "lastModified": 1773955630, @@ -8417,7 +8417,7 @@ }, "logos-nix_282": { "inputs": { - "nixpkgs": "nixpkgs_284" + "nixpkgs": "nixpkgs_285" }, "locked": { "lastModified": 1774455309, @@ -8435,7 +8435,7 @@ }, "logos-nix_283": { "inputs": { - "nixpkgs": "nixpkgs_285" + "nixpkgs": "nixpkgs_286" }, "locked": { "lastModified": 1773955630, @@ -8453,7 +8453,7 @@ }, "logos-nix_284": { "inputs": { - "nixpkgs": "nixpkgs_286" + "nixpkgs": "nixpkgs_287" }, "locked": { "lastModified": 1774455309, @@ -8471,7 +8471,7 @@ }, "logos-nix_285": { "inputs": { - "nixpkgs": "nixpkgs_287" + "nixpkgs": "nixpkgs_288" }, "locked": { "lastModified": 1773955630, @@ -8489,7 +8489,7 @@ }, "logos-nix_286": { "inputs": { - "nixpkgs": "nixpkgs_288" + "nixpkgs": "nixpkgs_289" }, "locked": { "lastModified": 1773955630, @@ -8507,7 +8507,7 @@ }, "logos-nix_287": { "inputs": { - "nixpkgs": "nixpkgs_289" + "nixpkgs": "nixpkgs_290" }, "locked": { "lastModified": 1774455309, @@ -8525,7 +8525,7 @@ }, "logos-nix_288": { "inputs": { - "nixpkgs": "nixpkgs_290" + "nixpkgs": "nixpkgs_291" }, "locked": { "lastModified": 1773955630, @@ -8543,7 +8543,7 @@ }, "logos-nix_289": { "inputs": { - "nixpkgs": "nixpkgs_291" + "nixpkgs": "nixpkgs_292" }, "locked": { "lastModified": 1773955630, @@ -8561,7 +8561,7 @@ }, "logos-nix_29": { "inputs": { - "nixpkgs": "nixpkgs_29" + "nixpkgs": "nixpkgs_30" }, "locked": { "lastModified": 1773955630, @@ -8579,7 +8579,7 @@ }, "logos-nix_290": { "inputs": { - "nixpkgs": "nixpkgs_292" + "nixpkgs": "nixpkgs_293" }, "locked": { "lastModified": 1773955630, @@ -8597,7 +8597,7 @@ }, "logos-nix_291": { "inputs": { - "nixpkgs": "nixpkgs_293" + "nixpkgs": "nixpkgs_294" }, "locked": { "lastModified": 1773955630, @@ -8615,7 +8615,7 @@ }, "logos-nix_292": { "inputs": { - "nixpkgs": "nixpkgs_294" + "nixpkgs": "nixpkgs_295" }, "locked": { "lastModified": 1774455309, @@ -8633,7 +8633,7 @@ }, "logos-nix_293": { "inputs": { - "nixpkgs": "nixpkgs_295" + "nixpkgs": "nixpkgs_296" }, "locked": { "lastModified": 1773955630, @@ -8651,7 +8651,7 @@ }, "logos-nix_294": { "inputs": { - "nixpkgs": "nixpkgs_296" + "nixpkgs": "nixpkgs_297" }, "locked": { "lastModified": 1774455309, @@ -8669,7 +8669,7 @@ }, "logos-nix_295": { "inputs": { - "nixpkgs": "nixpkgs_297" + "nixpkgs": "nixpkgs_298" }, "locked": { "lastModified": 1774455309, @@ -8687,7 +8687,7 @@ }, "logos-nix_296": { "inputs": { - "nixpkgs": "nixpkgs_298" + "nixpkgs": "nixpkgs_299" }, "locked": { "lastModified": 1773955630, @@ -8705,7 +8705,7 @@ }, "logos-nix_297": { "inputs": { - "nixpkgs": "nixpkgs_299" + "nixpkgs": "nixpkgs_300" }, "locked": { "lastModified": 1773955630, @@ -8723,7 +8723,7 @@ }, "logos-nix_298": { "inputs": { - "nixpkgs": "nixpkgs_300" + "nixpkgs": "nixpkgs_301" }, "locked": { "lastModified": 1773955630, @@ -8741,7 +8741,7 @@ }, "logos-nix_299": { "inputs": { - "nixpkgs": "nixpkgs_301" + "nixpkgs": "nixpkgs_302" }, "locked": { "lastModified": 1773955630, @@ -8759,7 +8759,7 @@ }, "logos-nix_3": { "inputs": { - "nixpkgs": "nixpkgs_3" + "nixpkgs": "nixpkgs_4" }, "locked": { "lastModified": 1774455309, @@ -8777,7 +8777,7 @@ }, "logos-nix_30": { "inputs": { - "nixpkgs": "nixpkgs_30" + "nixpkgs": "nixpkgs_31" }, "locked": { "lastModified": 1773955630, @@ -8795,7 +8795,7 @@ }, "logos-nix_300": { "inputs": { - "nixpkgs": "nixpkgs_302" + "nixpkgs": "nixpkgs_303" }, "locked": { "lastModified": 1773955630, @@ -8813,7 +8813,7 @@ }, "logos-nix_301": { "inputs": { - "nixpkgs": "nixpkgs_303" + "nixpkgs": "nixpkgs_304" }, "locked": { "lastModified": 1774455309, @@ -8831,7 +8831,7 @@ }, "logos-nix_302": { "inputs": { - "nixpkgs": "nixpkgs_304" + "nixpkgs": "nixpkgs_305" }, "locked": { "lastModified": 1773955630, @@ -8849,7 +8849,7 @@ }, "logos-nix_303": { "inputs": { - "nixpkgs": "nixpkgs_305" + "nixpkgs": "nixpkgs_306" }, "locked": { "lastModified": 1774455309, @@ -8867,7 +8867,7 @@ }, "logos-nix_304": { "inputs": { - "nixpkgs": "nixpkgs_306" + "nixpkgs": "nixpkgs_307" }, "locked": { "lastModified": 1774455309, @@ -8885,7 +8885,7 @@ }, "logos-nix_305": { "inputs": { - "nixpkgs": "nixpkgs_307" + "nixpkgs": "nixpkgs_308" }, "locked": { "lastModified": 1773955630, @@ -8903,7 +8903,7 @@ }, "logos-nix_306": { "inputs": { - "nixpkgs": "nixpkgs_308" + "nixpkgs": "nixpkgs_309" }, "locked": { "lastModified": 1773955630, @@ -8921,7 +8921,7 @@ }, "logos-nix_307": { "inputs": { - "nixpkgs": "nixpkgs_309" + "nixpkgs": "nixpkgs_310" }, "locked": { "lastModified": 1774455309, @@ -8939,7 +8939,7 @@ }, "logos-nix_308": { "inputs": { - "nixpkgs": "nixpkgs_310" + "nixpkgs": "nixpkgs_311" }, "locked": { "lastModified": 1774455309, @@ -8957,7 +8957,7 @@ }, "logos-nix_309": { "inputs": { - "nixpkgs": "nixpkgs_311" + "nixpkgs": "nixpkgs_312" }, "locked": { "lastModified": 1773955630, @@ -8975,7 +8975,7 @@ }, "logos-nix_31": { "inputs": { - "nixpkgs": "nixpkgs_31" + "nixpkgs": "nixpkgs_32" }, "locked": { "lastModified": 1773955630, @@ -8993,7 +8993,7 @@ }, "logos-nix_310": { "inputs": { - "nixpkgs": "nixpkgs_312" + "nixpkgs": "nixpkgs_313" }, "locked": { "lastModified": 1773955630, @@ -9011,7 +9011,7 @@ }, "logos-nix_311": { "inputs": { - "nixpkgs": "nixpkgs_313" + "nixpkgs": "nixpkgs_314" }, "locked": { "lastModified": 1774455309, @@ -9029,7 +9029,7 @@ }, "logos-nix_312": { "inputs": { - "nixpkgs": "nixpkgs_314" + "nixpkgs": "nixpkgs_315" }, "locked": { "lastModified": 1774455309, @@ -9047,7 +9047,7 @@ }, "logos-nix_313": { "inputs": { - "nixpkgs": "nixpkgs_315" + "nixpkgs": "nixpkgs_316" }, "locked": { "lastModified": 1773955630, @@ -9065,7 +9065,7 @@ }, "logos-nix_314": { "inputs": { - "nixpkgs": "nixpkgs_316" + "nixpkgs": "nixpkgs_317" }, "locked": { "lastModified": 1773955630, @@ -9083,7 +9083,7 @@ }, "logos-nix_315": { "inputs": { - "nixpkgs": "nixpkgs_317" + "nixpkgs": "nixpkgs_318" }, "locked": { "lastModified": 1773955630, @@ -9101,7 +9101,7 @@ }, "logos-nix_316": { "inputs": { - "nixpkgs": "nixpkgs_318" + "nixpkgs": "nixpkgs_319" }, "locked": { "lastModified": 1773955630, @@ -9119,7 +9119,7 @@ }, "logos-nix_317": { "inputs": { - "nixpkgs": "nixpkgs_319" + "nixpkgs": "nixpkgs_320" }, "locked": { "lastModified": 1774455309, @@ -9137,7 +9137,7 @@ }, "logos-nix_318": { "inputs": { - "nixpkgs": "nixpkgs_320" + "nixpkgs": "nixpkgs_321" }, "locked": { "lastModified": 1773955630, @@ -9155,7 +9155,7 @@ }, "logos-nix_319": { "inputs": { - "nixpkgs": "nixpkgs_321" + "nixpkgs": "nixpkgs_322" }, "locked": { "lastModified": 1773955630, @@ -9173,7 +9173,7 @@ }, "logos-nix_32": { "inputs": { - "nixpkgs": "nixpkgs_32" + "nixpkgs": "nixpkgs_33" }, "locked": { "lastModified": 1773955630, @@ -9191,7 +9191,7 @@ }, "logos-nix_320": { "inputs": { - "nixpkgs": "nixpkgs_322" + "nixpkgs": "nixpkgs_323" }, "locked": { "lastModified": 1774455309, @@ -9209,7 +9209,7 @@ }, "logos-nix_321": { "inputs": { - "nixpkgs": "nixpkgs_323" + "nixpkgs": "nixpkgs_324" }, "locked": { "lastModified": 1773955630, @@ -9227,7 +9227,7 @@ }, "logos-nix_322": { "inputs": { - "nixpkgs": "nixpkgs_324" + "nixpkgs": "nixpkgs_325" }, "locked": { "lastModified": 1774455309, @@ -9245,7 +9245,7 @@ }, "logos-nix_323": { "inputs": { - "nixpkgs": "nixpkgs_325" + "nixpkgs": "nixpkgs_326" }, "locked": { "lastModified": 1773955630, @@ -9263,7 +9263,7 @@ }, "logos-nix_324": { "inputs": { - "nixpkgs": "nixpkgs_326" + "nixpkgs": "nixpkgs_327" }, "locked": { "lastModified": 1774455309, @@ -9281,7 +9281,7 @@ }, "logos-nix_325": { "inputs": { - "nixpkgs": "nixpkgs_327" + "nixpkgs": "nixpkgs_328" }, "locked": { "lastModified": 1773955630, @@ -9299,7 +9299,7 @@ }, "logos-nix_326": { "inputs": { - "nixpkgs": "nixpkgs_328" + "nixpkgs": "nixpkgs_329" }, "locked": { "lastModified": 1774455309, @@ -9317,7 +9317,7 @@ }, "logos-nix_327": { "inputs": { - "nixpkgs": "nixpkgs_329" + "nixpkgs": "nixpkgs_330" }, "locked": { "lastModified": 1773955630, @@ -9335,7 +9335,7 @@ }, "logos-nix_328": { "inputs": { - "nixpkgs": "nixpkgs_330" + "nixpkgs": "nixpkgs_331" }, "locked": { "lastModified": 1774455309, @@ -9353,7 +9353,7 @@ }, "logos-nix_329": { "inputs": { - "nixpkgs": "nixpkgs_331" + "nixpkgs": "nixpkgs_332" }, "locked": { "lastModified": 1773955630, @@ -9371,7 +9371,7 @@ }, "logos-nix_33": { "inputs": { - "nixpkgs": "nixpkgs_33" + "nixpkgs": "nixpkgs_34" }, "locked": { "lastModified": 1774455309, @@ -9389,7 +9389,7 @@ }, "logos-nix_330": { "inputs": { - "nixpkgs": "nixpkgs_332" + "nixpkgs": "nixpkgs_333" }, "locked": { "lastModified": 1773955630, @@ -9407,7 +9407,7 @@ }, "logos-nix_331": { "inputs": { - "nixpkgs": "nixpkgs_333" + "nixpkgs": "nixpkgs_334" }, "locked": { "lastModified": 1774455309, @@ -9425,7 +9425,7 @@ }, "logos-nix_332": { "inputs": { - "nixpkgs": "nixpkgs_334" + "nixpkgs": "nixpkgs_335" }, "locked": { "lastModified": 1773955630, @@ -9443,7 +9443,7 @@ }, "logos-nix_333": { "inputs": { - "nixpkgs": "nixpkgs_335" + "nixpkgs": "nixpkgs_336" }, "locked": { "lastModified": 1773955630, @@ -9461,7 +9461,7 @@ }, "logos-nix_334": { "inputs": { - "nixpkgs": "nixpkgs_336" + "nixpkgs": "nixpkgs_337" }, "locked": { "lastModified": 1773955630, @@ -9479,7 +9479,7 @@ }, "logos-nix_335": { "inputs": { - "nixpkgs": "nixpkgs_337" + "nixpkgs": "nixpkgs_338" }, "locked": { "lastModified": 1773955630, @@ -9497,7 +9497,7 @@ }, "logos-nix_336": { "inputs": { - "nixpkgs": "nixpkgs_338" + "nixpkgs": "nixpkgs_339" }, "locked": { "lastModified": 1774455309, @@ -9515,7 +9515,7 @@ }, "logos-nix_337": { "inputs": { - "nixpkgs": "nixpkgs_339" + "nixpkgs": "nixpkgs_340" }, "locked": { "lastModified": 1773955630, @@ -9533,7 +9533,7 @@ }, "logos-nix_338": { "inputs": { - "nixpkgs": "nixpkgs_340" + "nixpkgs": "nixpkgs_341" }, "locked": { "lastModified": 1774455309, @@ -9551,7 +9551,7 @@ }, "logos-nix_339": { "inputs": { - "nixpkgs": "nixpkgs_341" + "nixpkgs": "nixpkgs_342" }, "locked": { "lastModified": 1774455309, @@ -9569,7 +9569,7 @@ }, "logos-nix_34": { "inputs": { - "nixpkgs": "nixpkgs_34" + "nixpkgs": "nixpkgs_35" }, "locked": { "lastModified": 1773955630, @@ -9587,7 +9587,7 @@ }, "logos-nix_340": { "inputs": { - "nixpkgs": "nixpkgs_342" + "nixpkgs": "nixpkgs_343" }, "locked": { "lastModified": 1773955630, @@ -9605,7 +9605,7 @@ }, "logos-nix_341": { "inputs": { - "nixpkgs": "nixpkgs_343" + "nixpkgs": "nixpkgs_344" }, "locked": { "lastModified": 1773955630, @@ -9623,7 +9623,7 @@ }, "logos-nix_342": { "inputs": { - "nixpkgs": "nixpkgs_344" + "nixpkgs": "nixpkgs_345" }, "locked": { "lastModified": 1773955630, @@ -9641,7 +9641,7 @@ }, "logos-nix_343": { "inputs": { - "nixpkgs": "nixpkgs_345" + "nixpkgs": "nixpkgs_346" }, "locked": { "lastModified": 1773955630, @@ -9659,7 +9659,7 @@ }, "logos-nix_344": { "inputs": { - "nixpkgs": "nixpkgs_346" + "nixpkgs": "nixpkgs_347" }, "locked": { "lastModified": 1773955630, @@ -9677,7 +9677,7 @@ }, "logos-nix_345": { "inputs": { - "nixpkgs": "nixpkgs_347" + "nixpkgs": "nixpkgs_348" }, "locked": { "lastModified": 1774455309, @@ -9695,7 +9695,7 @@ }, "logos-nix_346": { "inputs": { - "nixpkgs": "nixpkgs_348" + "nixpkgs": "nixpkgs_349" }, "locked": { "lastModified": 1773955630, @@ -9713,7 +9713,7 @@ }, "logos-nix_347": { "inputs": { - "nixpkgs": "nixpkgs_349" + "nixpkgs": "nixpkgs_350" }, "locked": { "lastModified": 1774455309, @@ -9731,7 +9731,7 @@ }, "logos-nix_348": { "inputs": { - "nixpkgs": "nixpkgs_350" + "nixpkgs": "nixpkgs_351" }, "locked": { "lastModified": 1774455309, @@ -9749,7 +9749,7 @@ }, "logos-nix_349": { "inputs": { - "nixpkgs": "nixpkgs_351" + "nixpkgs": "nixpkgs_352" }, "locked": { "lastModified": 1773955630, @@ -9767,7 +9767,7 @@ }, "logos-nix_35": { "inputs": { - "nixpkgs": "nixpkgs_35" + "nixpkgs": "nixpkgs_36" }, "locked": { "lastModified": 1774455309, @@ -9785,7 +9785,7 @@ }, "logos-nix_350": { "inputs": { - "nixpkgs": "nixpkgs_352" + "nixpkgs": "nixpkgs_353" }, "locked": { "lastModified": 1773955630, @@ -9803,7 +9803,7 @@ }, "logos-nix_351": { "inputs": { - "nixpkgs": "nixpkgs_353" + "nixpkgs": "nixpkgs_354" }, "locked": { "lastModified": 1774455309, @@ -9821,7 +9821,7 @@ }, "logos-nix_352": { "inputs": { - "nixpkgs": "nixpkgs_354" + "nixpkgs": "nixpkgs_355" }, "locked": { "lastModified": 1774455309, @@ -9839,7 +9839,7 @@ }, "logos-nix_353": { "inputs": { - "nixpkgs": "nixpkgs_355" + "nixpkgs": "nixpkgs_356" }, "locked": { "lastModified": 1773955630, @@ -9857,7 +9857,7 @@ }, "logos-nix_354": { "inputs": { - "nixpkgs": "nixpkgs_356" + "nixpkgs": "nixpkgs_357" }, "locked": { "lastModified": 1773955630, @@ -9875,7 +9875,7 @@ }, "logos-nix_355": { "inputs": { - "nixpkgs": "nixpkgs_357" + "nixpkgs": "nixpkgs_358" }, "locked": { "lastModified": 1774455309, @@ -9893,7 +9893,7 @@ }, "logos-nix_356": { "inputs": { - "nixpkgs": "nixpkgs_358" + "nixpkgs": "nixpkgs_359" }, "locked": { "lastModified": 1774455309, @@ -9911,7 +9911,7 @@ }, "logos-nix_357": { "inputs": { - "nixpkgs": "nixpkgs_359" + "nixpkgs": "nixpkgs_360" }, "locked": { "lastModified": 1773955630, @@ -9929,7 +9929,7 @@ }, "logos-nix_358": { "inputs": { - "nixpkgs": "nixpkgs_360" + "nixpkgs": "nixpkgs_361" }, "locked": { "lastModified": 1773955630, @@ -9947,7 +9947,7 @@ }, "logos-nix_359": { "inputs": { - "nixpkgs": "nixpkgs_361" + "nixpkgs": "nixpkgs_362" }, "locked": { "lastModified": 1773955630, @@ -9965,7 +9965,7 @@ }, "logos-nix_36": { "inputs": { - "nixpkgs": "nixpkgs_36" + "nixpkgs": "nixpkgs_37" }, "locked": { "lastModified": 1774455309, @@ -9983,7 +9983,7 @@ }, "logos-nix_360": { "inputs": { - "nixpkgs": "nixpkgs_362" + "nixpkgs": "nixpkgs_363" }, "locked": { "lastModified": 1773955630, @@ -10001,7 +10001,7 @@ }, "logos-nix_361": { "inputs": { - "nixpkgs": "nixpkgs_363" + "nixpkgs": "nixpkgs_364" }, "locked": { "lastModified": 1774455309, @@ -10019,7 +10019,7 @@ }, "logos-nix_362": { "inputs": { - "nixpkgs": "nixpkgs_364" + "nixpkgs": "nixpkgs_365" }, "locked": { "lastModified": 1773955630, @@ -10037,7 +10037,7 @@ }, "logos-nix_363": { "inputs": { - "nixpkgs": "nixpkgs_365" + "nixpkgs": "nixpkgs_366" }, "locked": { "lastModified": 1773955630, @@ -10055,7 +10055,7 @@ }, "logos-nix_364": { "inputs": { - "nixpkgs": "nixpkgs_366" + "nixpkgs": "nixpkgs_367" }, "locked": { "lastModified": 1774455309, @@ -10073,7 +10073,7 @@ }, "logos-nix_365": { "inputs": { - "nixpkgs": "nixpkgs_367" + "nixpkgs": "nixpkgs_368" }, "locked": { "lastModified": 1773955630, @@ -10091,7 +10091,7 @@ }, "logos-nix_366": { "inputs": { - "nixpkgs": "nixpkgs_368" + "nixpkgs": "nixpkgs_369" }, "locked": { "lastModified": 1774455309, @@ -10109,7 +10109,7 @@ }, "logos-nix_367": { "inputs": { - "nixpkgs": "nixpkgs_369" + "nixpkgs": "nixpkgs_370" }, "locked": { "lastModified": 1774455309, @@ -10127,7 +10127,7 @@ }, "logos-nix_368": { "inputs": { - "nixpkgs": "nixpkgs_370" + "nixpkgs": "nixpkgs_371" }, "locked": { "lastModified": 1773955630, @@ -10145,7 +10145,7 @@ }, "logos-nix_369": { "inputs": { - "nixpkgs": "nixpkgs_371" + "nixpkgs": "nixpkgs_372" }, "locked": { "lastModified": 1773955630, @@ -10163,7 +10163,7 @@ }, "logos-nix_37": { "inputs": { - "nixpkgs": "nixpkgs_37" + "nixpkgs": "nixpkgs_38" }, "locked": { "lastModified": 1773955630, @@ -10181,7 +10181,7 @@ }, "logos-nix_370": { "inputs": { - "nixpkgs": "nixpkgs_372" + "nixpkgs": "nixpkgs_373" }, "locked": { "lastModified": 1773955630, @@ -10199,7 +10199,7 @@ }, "logos-nix_371": { "inputs": { - "nixpkgs": "nixpkgs_373" + "nixpkgs": "nixpkgs_374" }, "locked": { "lastModified": 1773955630, @@ -10217,7 +10217,7 @@ }, "logos-nix_372": { "inputs": { - "nixpkgs": "nixpkgs_374" + "nixpkgs": "nixpkgs_375" }, "locked": { "lastModified": 1774455309, @@ -10235,7 +10235,7 @@ }, "logos-nix_373": { "inputs": { - "nixpkgs": "nixpkgs_375" + "nixpkgs": "nixpkgs_376" }, "locked": { "lastModified": 1774455309, @@ -10253,7 +10253,7 @@ }, "logos-nix_374": { "inputs": { - "nixpkgs": "nixpkgs_376" + "nixpkgs": "nixpkgs_377" }, "locked": { "lastModified": 1773955630, @@ -10271,7 +10271,7 @@ }, "logos-nix_375": { "inputs": { - "nixpkgs": "nixpkgs_377" + "nixpkgs": "nixpkgs_378" }, "locked": { "lastModified": 1774455309, @@ -10289,7 +10289,7 @@ }, "logos-nix_376": { "inputs": { - "nixpkgs": "nixpkgs_378" + "nixpkgs": "nixpkgs_379" }, "locked": { "lastModified": 1773955630, @@ -10307,7 +10307,7 @@ }, "logos-nix_377": { "inputs": { - "nixpkgs": "nixpkgs_379" + "nixpkgs": "nixpkgs_380" }, "locked": { "lastModified": 1774455309, @@ -10325,7 +10325,7 @@ }, "logos-nix_378": { "inputs": { - "nixpkgs": "nixpkgs_380" + "nixpkgs": "nixpkgs_381" }, "locked": { "lastModified": 1774455309, @@ -10343,7 +10343,7 @@ }, "logos-nix_379": { "inputs": { - "nixpkgs": "nixpkgs_381" + "nixpkgs": "nixpkgs_382" }, "locked": { "lastModified": 1773955630, @@ -10361,7 +10361,7 @@ }, "logos-nix_38": { "inputs": { - "nixpkgs": "nixpkgs_38" + "nixpkgs": "nixpkgs_39" }, "locked": { "lastModified": 1773955630, @@ -10379,7 +10379,7 @@ }, "logos-nix_380": { "inputs": { - "nixpkgs": "nixpkgs_382" + "nixpkgs": "nixpkgs_383" }, "locked": { "lastModified": 1773955630, @@ -10397,7 +10397,7 @@ }, "logos-nix_381": { "inputs": { - "nixpkgs": "nixpkgs_383" + "nixpkgs": "nixpkgs_384" }, "locked": { "lastModified": 1774455309, @@ -10415,7 +10415,7 @@ }, "logos-nix_382": { "inputs": { - "nixpkgs": "nixpkgs_384" + "nixpkgs": "nixpkgs_385" }, "locked": { "lastModified": 1774455309, @@ -10433,7 +10433,7 @@ }, "logos-nix_383": { "inputs": { - "nixpkgs": "nixpkgs_385" + "nixpkgs": "nixpkgs_386" }, "locked": { "lastModified": 1773955630, @@ -10451,7 +10451,7 @@ }, "logos-nix_384": { "inputs": { - "nixpkgs": "nixpkgs_386" + "nixpkgs": "nixpkgs_387" }, "locked": { "lastModified": 1773955630, @@ -10469,7 +10469,7 @@ }, "logos-nix_385": { "inputs": { - "nixpkgs": "nixpkgs_387" + "nixpkgs": "nixpkgs_388" }, "locked": { "lastModified": 1774455309, @@ -10487,7 +10487,7 @@ }, "logos-nix_386": { "inputs": { - "nixpkgs": "nixpkgs_388" + "nixpkgs": "nixpkgs_389" }, "locked": { "lastModified": 1774455309, @@ -10505,7 +10505,7 @@ }, "logos-nix_387": { "inputs": { - "nixpkgs": "nixpkgs_389" + "nixpkgs": "nixpkgs_390" }, "locked": { "lastModified": 1773955630, @@ -10523,7 +10523,7 @@ }, "logos-nix_388": { "inputs": { - "nixpkgs": "nixpkgs_390" + "nixpkgs": "nixpkgs_391" }, "locked": { "lastModified": 1773955630, @@ -10541,7 +10541,7 @@ }, "logos-nix_389": { "inputs": { - "nixpkgs": "nixpkgs_391" + "nixpkgs": "nixpkgs_392" }, "locked": { "lastModified": 1773955630, @@ -10559,7 +10559,7 @@ }, "logos-nix_39": { "inputs": { - "nixpkgs": "nixpkgs_39" + "nixpkgs": "nixpkgs_40" }, "locked": { "lastModified": 1774455309, @@ -10577,7 +10577,7 @@ }, "logos-nix_390": { "inputs": { - "nixpkgs": "nixpkgs_392" + "nixpkgs": "nixpkgs_393" }, "locked": { "lastModified": 1773955630, @@ -10595,7 +10595,7 @@ }, "logos-nix_391": { "inputs": { - "nixpkgs": "nixpkgs_393" + "nixpkgs": "nixpkgs_394" }, "locked": { "lastModified": 1774455309, @@ -10613,7 +10613,7 @@ }, "logos-nix_392": { "inputs": { - "nixpkgs": "nixpkgs_394" + "nixpkgs": "nixpkgs_395" }, "locked": { "lastModified": 1773955630, @@ -10631,7 +10631,7 @@ }, "logos-nix_393": { "inputs": { - "nixpkgs": "nixpkgs_395" + "nixpkgs": "nixpkgs_396" }, "locked": { "lastModified": 1773955630, @@ -10649,7 +10649,7 @@ }, "logos-nix_394": { "inputs": { - "nixpkgs": "nixpkgs_396" + "nixpkgs": "nixpkgs_397" }, "locked": { "lastModified": 1774455309, @@ -10667,7 +10667,7 @@ }, "logos-nix_395": { "inputs": { - "nixpkgs": "nixpkgs_397" + "nixpkgs": "nixpkgs_398" }, "locked": { "lastModified": 1773955630, @@ -10685,7 +10685,7 @@ }, "logos-nix_396": { "inputs": { - "nixpkgs": "nixpkgs_398" + "nixpkgs": "nixpkgs_399" }, "locked": { "lastModified": 1773955630, @@ -10703,7 +10703,7 @@ }, "logos-nix_4": { "inputs": { - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1773955630, @@ -10721,7 +10721,7 @@ }, "logos-nix_40": { "inputs": { - "nixpkgs": "nixpkgs_40" + "nixpkgs": "nixpkgs_41" }, "locked": { "lastModified": 1774455309, @@ -10739,7 +10739,7 @@ }, "logos-nix_41": { "inputs": { - "nixpkgs": "nixpkgs_41" + "nixpkgs": "nixpkgs_42" }, "locked": { "lastModified": 1773955630, @@ -10757,7 +10757,7 @@ }, "logos-nix_42": { "inputs": { - "nixpkgs": "nixpkgs_42" + "nixpkgs": "nixpkgs_43" }, "locked": { "lastModified": 1773955630, @@ -10775,7 +10775,7 @@ }, "logos-nix_43": { "inputs": { - "nixpkgs": "nixpkgs_43" + "nixpkgs": "nixpkgs_44" }, "locked": { "lastModified": 1774455309, @@ -10793,7 +10793,7 @@ }, "logos-nix_44": { "inputs": { - "nixpkgs": "nixpkgs_44" + "nixpkgs": "nixpkgs_45" }, "locked": { "lastModified": 1774455309, @@ -10811,7 +10811,7 @@ }, "logos-nix_45": { "inputs": { - "nixpkgs": "nixpkgs_45" + "nixpkgs": "nixpkgs_46" }, "locked": { "lastModified": 1773955630, @@ -10829,7 +10829,7 @@ }, "logos-nix_46": { "inputs": { - "nixpkgs": "nixpkgs_46" + "nixpkgs": "nixpkgs_47" }, "locked": { "lastModified": 1773955630, @@ -10847,7 +10847,7 @@ }, "logos-nix_47": { "inputs": { - "nixpkgs": "nixpkgs_47" + "nixpkgs": "nixpkgs_48" }, "locked": { "lastModified": 1773955630, @@ -10865,7 +10865,7 @@ }, "logos-nix_48": { "inputs": { - "nixpkgs": "nixpkgs_48" + "nixpkgs": "nixpkgs_49" }, "locked": { "lastModified": 1773955630, @@ -10883,7 +10883,7 @@ }, "logos-nix_49": { "inputs": { - "nixpkgs": "nixpkgs_49" + "nixpkgs": "nixpkgs_50" }, "locked": { "lastModified": 1774455309, @@ -10901,7 +10901,7 @@ }, "logos-nix_5": { "inputs": { - "nixpkgs": "nixpkgs_5" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1774455309, @@ -10919,7 +10919,7 @@ }, "logos-nix_50": { "inputs": { - "nixpkgs": "nixpkgs_50" + "nixpkgs": "nixpkgs_51" }, "locked": { "lastModified": 1773955630, @@ -10937,7 +10937,7 @@ }, "logos-nix_51": { "inputs": { - "nixpkgs": "nixpkgs_51" + "nixpkgs": "nixpkgs_52" }, "locked": { "lastModified": 1773955630, @@ -10955,7 +10955,7 @@ }, "logos-nix_52": { "inputs": { - "nixpkgs": "nixpkgs_52" + "nixpkgs": "nixpkgs_53" }, "locked": { "lastModified": 1774455309, @@ -10973,7 +10973,7 @@ }, "logos-nix_53": { "inputs": { - "nixpkgs": "nixpkgs_53" + "nixpkgs": "nixpkgs_54" }, "locked": { "lastModified": 1773955630, @@ -10991,7 +10991,7 @@ }, "logos-nix_54": { "inputs": { - "nixpkgs": "nixpkgs_54" + "nixpkgs": "nixpkgs_55" }, "locked": { "lastModified": 1774455309, @@ -11009,7 +11009,7 @@ }, "logos-nix_55": { "inputs": { - "nixpkgs": "nixpkgs_55" + "nixpkgs": "nixpkgs_56" }, "locked": { "lastModified": 1773955630, @@ -11027,7 +11027,7 @@ }, "logos-nix_56": { "inputs": { - "nixpkgs": "nixpkgs_56" + "nixpkgs": "nixpkgs_57" }, "locked": { "lastModified": 1774455309, @@ -11045,7 +11045,7 @@ }, "logos-nix_57": { "inputs": { - "nixpkgs": "nixpkgs_57" + "nixpkgs": "nixpkgs_58" }, "locked": { "lastModified": 1773955630, @@ -11063,7 +11063,7 @@ }, "logos-nix_58": { "inputs": { - "nixpkgs": "nixpkgs_58" + "nixpkgs": "nixpkgs_59" }, "locked": { "lastModified": 1774455309, @@ -11081,7 +11081,7 @@ }, "logos-nix_59": { "inputs": { - "nixpkgs": "nixpkgs_59" + "nixpkgs": "nixpkgs_60" }, "locked": { "lastModified": 1773955630, @@ -11099,7 +11099,7 @@ }, "logos-nix_6": { "inputs": { - "nixpkgs": "nixpkgs_6" + "nixpkgs": "nixpkgs_7" }, "locked": { "lastModified": 1773955630, @@ -11117,7 +11117,7 @@ }, "logos-nix_60": { "inputs": { - "nixpkgs": "nixpkgs_60" + "nixpkgs": "nixpkgs_61" }, "locked": { "lastModified": 1774455309, @@ -11135,7 +11135,7 @@ }, "logos-nix_61": { "inputs": { - "nixpkgs": "nixpkgs_61" + "nixpkgs": "nixpkgs_62" }, "locked": { "lastModified": 1773955630, @@ -11153,7 +11153,7 @@ }, "logos-nix_62": { "inputs": { - "nixpkgs": "nixpkgs_62" + "nixpkgs": "nixpkgs_63" }, "locked": { "lastModified": 1773955630, @@ -11171,7 +11171,7 @@ }, "logos-nix_63": { "inputs": { - "nixpkgs": "nixpkgs_63" + "nixpkgs": "nixpkgs_64" }, "locked": { "lastModified": 1774455309, @@ -11189,7 +11189,7 @@ }, "logos-nix_64": { "inputs": { - "nixpkgs": "nixpkgs_64" + "nixpkgs": "nixpkgs_65" }, "locked": { "lastModified": 1773955630, @@ -11207,7 +11207,7 @@ }, "logos-nix_65": { "inputs": { - "nixpkgs": "nixpkgs_65" + "nixpkgs": "nixpkgs_66" }, "locked": { "lastModified": 1773955630, @@ -11225,7 +11225,7 @@ }, "logos-nix_66": { "inputs": { - "nixpkgs": "nixpkgs_66" + "nixpkgs": "nixpkgs_67" }, "locked": { "lastModified": 1773955630, @@ -11243,7 +11243,7 @@ }, "logos-nix_67": { "inputs": { - "nixpkgs": "nixpkgs_67" + "nixpkgs": "nixpkgs_68" }, "locked": { "lastModified": 1773955630, @@ -11261,7 +11261,7 @@ }, "logos-nix_68": { "inputs": { - "nixpkgs": "nixpkgs_68" + "nixpkgs": "nixpkgs_69" }, "locked": { "lastModified": 1774455309, @@ -11279,7 +11279,7 @@ }, "logos-nix_69": { "inputs": { - "nixpkgs": "nixpkgs_69" + "nixpkgs": "nixpkgs_70" }, "locked": { "lastModified": 1773955630, @@ -11297,7 +11297,7 @@ }, "logos-nix_7": { "inputs": { - "nixpkgs": "nixpkgs_7" + "nixpkgs": "nixpkgs_8" }, "locked": { "lastModified": 1774455309, @@ -11315,7 +11315,7 @@ }, "logos-nix_70": { "inputs": { - "nixpkgs": "nixpkgs_70" + "nixpkgs": "nixpkgs_71" }, "locked": { "lastModified": 1774455309, @@ -11333,7 +11333,7 @@ }, "logos-nix_71": { "inputs": { - "nixpkgs": "nixpkgs_71" + "nixpkgs": "nixpkgs_72" }, "locked": { "lastModified": 1774455309, @@ -11351,7 +11351,7 @@ }, "logos-nix_72": { "inputs": { - "nixpkgs": "nixpkgs_72" + "nixpkgs": "nixpkgs_73" }, "locked": { "lastModified": 1773955630, @@ -11369,7 +11369,7 @@ }, "logos-nix_73": { "inputs": { - "nixpkgs": "nixpkgs_73" + "nixpkgs": "nixpkgs_74" }, "locked": { "lastModified": 1773955630, @@ -11387,7 +11387,7 @@ }, "logos-nix_74": { "inputs": { - "nixpkgs": "nixpkgs_74" + "nixpkgs": "nixpkgs_75" }, "locked": { "lastModified": 1773955630, @@ -11405,7 +11405,7 @@ }, "logos-nix_75": { "inputs": { - "nixpkgs": "nixpkgs_75" + "nixpkgs": "nixpkgs_76" }, "locked": { "lastModified": 1773955630, @@ -11423,7 +11423,7 @@ }, "logos-nix_76": { "inputs": { - "nixpkgs": "nixpkgs_76" + "nixpkgs": "nixpkgs_77" }, "locked": { "lastModified": 1773955630, @@ -11441,7 +11441,7 @@ }, "logos-nix_77": { "inputs": { - "nixpkgs": "nixpkgs_77" + "nixpkgs": "nixpkgs_78" }, "locked": { "lastModified": 1774455309, @@ -11459,7 +11459,7 @@ }, "logos-nix_78": { "inputs": { - "nixpkgs": "nixpkgs_78" + "nixpkgs": "nixpkgs_79" }, "locked": { "lastModified": 1773955630, @@ -11477,7 +11477,7 @@ }, "logos-nix_79": { "inputs": { - "nixpkgs": "nixpkgs_79" + "nixpkgs": "nixpkgs_80" }, "locked": { "lastModified": 1774455309, @@ -11495,7 +11495,7 @@ }, "logos-nix_8": { "inputs": { - "nixpkgs": "nixpkgs_8" + "nixpkgs": "nixpkgs_9" }, "locked": { "lastModified": 1774455309, @@ -11513,7 +11513,7 @@ }, "logos-nix_80": { "inputs": { - "nixpkgs": "nixpkgs_80" + "nixpkgs": "nixpkgs_81" }, "locked": { "lastModified": 1774455309, @@ -11531,7 +11531,7 @@ }, "logos-nix_81": { "inputs": { - "nixpkgs": "nixpkgs_81" + "nixpkgs": "nixpkgs_82" }, "locked": { "lastModified": 1773955630, @@ -11549,7 +11549,7 @@ }, "logos-nix_82": { "inputs": { - "nixpkgs": "nixpkgs_82" + "nixpkgs": "nixpkgs_83" }, "locked": { "lastModified": 1773955630, @@ -11567,7 +11567,7 @@ }, "logos-nix_83": { "inputs": { - "nixpkgs": "nixpkgs_83" + "nixpkgs": "nixpkgs_84" }, "locked": { "lastModified": 1774455309, @@ -11585,7 +11585,7 @@ }, "logos-nix_84": { "inputs": { - "nixpkgs": "nixpkgs_84" + "nixpkgs": "nixpkgs_85" }, "locked": { "lastModified": 1774455309, @@ -11603,7 +11603,7 @@ }, "logos-nix_85": { "inputs": { - "nixpkgs": "nixpkgs_85" + "nixpkgs": "nixpkgs_86" }, "locked": { "lastModified": 1773955630, @@ -11621,7 +11621,7 @@ }, "logos-nix_86": { "inputs": { - "nixpkgs": "nixpkgs_86" + "nixpkgs": "nixpkgs_87" }, "locked": { "lastModified": 1773955630, @@ -11639,7 +11639,7 @@ }, "logos-nix_87": { "inputs": { - "nixpkgs": "nixpkgs_87" + "nixpkgs": "nixpkgs_88" }, "locked": { "lastModified": 1774455309, @@ -11657,7 +11657,7 @@ }, "logos-nix_88": { "inputs": { - "nixpkgs": "nixpkgs_88" + "nixpkgs": "nixpkgs_89" }, "locked": { "lastModified": 1774455309, @@ -11675,7 +11675,7 @@ }, "logos-nix_89": { "inputs": { - "nixpkgs": "nixpkgs_89" + "nixpkgs": "nixpkgs_90" }, "locked": { "lastModified": 1773955630, @@ -11693,7 +11693,7 @@ }, "logos-nix_9": { "inputs": { - "nixpkgs": "nixpkgs_9" + "nixpkgs": "nixpkgs_10" }, "locked": { "lastModified": 1774455309, @@ -11711,7 +11711,7 @@ }, "logos-nix_90": { "inputs": { - "nixpkgs": "nixpkgs_90" + "nixpkgs": "nixpkgs_91" }, "locked": { "lastModified": 1773955630, @@ -11729,7 +11729,7 @@ }, "logos-nix_91": { "inputs": { - "nixpkgs": "nixpkgs_91" + "nixpkgs": "nixpkgs_92" }, "locked": { "lastModified": 1773955630, @@ -11747,7 +11747,7 @@ }, "logos-nix_92": { "inputs": { - "nixpkgs": "nixpkgs_92" + "nixpkgs": "nixpkgs_93" }, "locked": { "lastModified": 1773955630, @@ -11765,7 +11765,7 @@ }, "logos-nix_93": { "inputs": { - "nixpkgs": "nixpkgs_93" + "nixpkgs": "nixpkgs_94" }, "locked": { "lastModified": 1774455309, @@ -11783,7 +11783,7 @@ }, "logos-nix_94": { "inputs": { - "nixpkgs": "nixpkgs_94" + "nixpkgs": "nixpkgs_95" }, "locked": { "lastModified": 1773955630, @@ -11801,7 +11801,7 @@ }, "logos-nix_95": { "inputs": { - "nixpkgs": "nixpkgs_95" + "nixpkgs": "nixpkgs_96" }, "locked": { "lastModified": 1773955630, @@ -11819,7 +11819,7 @@ }, "logos-nix_96": { "inputs": { - "nixpkgs": "nixpkgs_96" + "nixpkgs": "nixpkgs_97" }, "locked": { "lastModified": 1774455309, @@ -11837,7 +11837,7 @@ }, "logos-nix_97": { "inputs": { - "nixpkgs": "nixpkgs_97" + "nixpkgs": "nixpkgs_98" }, "locked": { "lastModified": 1773955630, @@ -11855,7 +11855,7 @@ }, "logos-nix_98": { "inputs": { - "nixpkgs": "nixpkgs_98" + "nixpkgs": "nixpkgs_99" }, "locked": { "lastModified": 1774455309, @@ -11873,7 +11873,7 @@ }, "logos-nix_99": { "inputs": { - "nixpkgs": "nixpkgs_99" + "nixpkgs": "nixpkgs_100" }, "locked": { "lastModified": 1774455309, @@ -14438,11 +14438,11 @@ ] }, "locked": { - "lastModified": 1782082589, - "narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=", + "lastModified": 1782920676, + "narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=", "owner": "logos-co", "repo": "logos-protocol", - "rev": "315a3a2e0af61bc47aad5601ee44cd7689975820", + "rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1", "type": "github" }, "original": { @@ -16410,17 +16410,17 @@ "nix-bundle-lgx": "nix-bundle-lgx_28" }, "locked": { - "lastModified": 1782313954, - "narHash": "sha256-psh5EtcIZh6kzFD4pQDT8bae9JS9eVtk5nPWxR2aGSA=", + "lastModified": 1782741385, + "narHash": "sha256-kN6xb1b/Jvu9Ninaqtmi/C4fx8poisGYbThIBIXwvHw=", "owner": "logos-blockchain", "repo": "logos-execution-zone-module", - "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c", + "rev": "d70225ced646934d2294fd9e8f8b03615c104b80", "type": "github" }, "original": { "owner": "logos-blockchain", "repo": "logos-execution-zone-module", - "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c", + "rev": "d70225ced646934d2294fd9e8f8b03615c104b80", "type": "github" } }, @@ -20096,22 +20096,6 @@ "type": "github" } }, - "nixpkgs": { - "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "nixpkgs_10": { "locked": { "lastModified": 1759036355, @@ -20546,32 +20530,32 @@ }, "nixpkgs_124": { "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_125": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } @@ -23138,32 +23122,32 @@ }, "nixpkgs_270": { "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_271": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } @@ -25408,6 +25392,22 @@ "type": "github" } }, + "nixpkgs_399": { + "locked": { + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_4": { "locked": { "lastModified": 1759036355, @@ -26763,9 +26763,22 @@ "root": { "inputs": { "logos-module-builder": "logos-module-builder", - "logos_execution_zone": "logos_execution_zone" + "logos_execution_zone": "logos_execution_zone", + "shared_wallet": "shared_wallet" } }, + "shared_wallet": { + "flake": false, + "locked": { + "path": "../shared/wallet", + "type": "path" + }, + "original": { + "path": "../shared/wallet", + "type": "path" + }, + "parent": [] + }, "rust-overlay": { "inputs": { "nixpkgs": [ @@ -26833,7 +26846,7 @@ }, "rust-rapidsnark": { "inputs": { - "nixpkgs": "nixpkgs_270" + "nixpkgs": "nixpkgs_271" }, "locked": { "lastModified": 1781090841, diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 0811f1bb..7f345e11 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -4,17 +4,41 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; + # Shared C++ wallet access and Logos.Wallet QML sources. + shared_wallet = { + url = "path:../shared/wallet"; + flake = false; + }; + # Core wallet module (the LEZ wallet FFI Qt plugin). The input name must # match the metadata.json `dependencies` entry so the builder can resolve - # it as a module dependency. This rev pins LEZ (lssa) at fb8cbac4, which - # includes the macOS Metal-build fix, so no `--override-input` is needed. - logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d2e9400ac06c3cdbfc2405b4f153fff9841a453c"; + # it as a module dependency. This revision exposes generic transaction + # submission by deployed program ID. + logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80"; }; - outputs = inputs@{ logos-module-builder, ... }: + outputs = inputs@{ logos-module-builder, shared_wallet, ... }: logos-module-builder.lib.mkLogosQmlModule { src = ./.; configFile = ./metadata.json; flakeInputs = inputs; + preConfigure = '' + cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}") + ''; + postInstall = '' + # The builder installs the view under lib/qml after this hook. Its + # import descriptor points back to this compiled shared QML module. + test -f ${./qml}/Logos/Wallet/qmldir + + walletQmlDir="shared-wallet/qml/Logos/Wallet" + if [ ! -d "$walletQmlDir" ]; then + echo "Built Logos.Wallet QML module not found" + exit 1 + fi + walletQmlInstallDir="$out/lib/Logos/Wallet" + mkdir -p "$walletQmlInstallDir" + cp -r "$walletQmlDir/." "$walletQmlInstallDir/" + test -f "$walletQmlInstallDir/qmldir" + ''; }; } diff --git a/apps/amm/qml/Logos/Wallet/qmldir b/apps/amm/qml/Logos/Wallet/qmldir new file mode 100644 index 00000000..866351b9 --- /dev/null +++ b/apps/amm/qml/Logos/Wallet/qmldir @@ -0,0 +1,5 @@ +module Logos.Wallet +optional plugin logos_wallet_qmlplugin ../../../Logos/Wallet +classname Logos_WalletPlugin +prefer :/qt/qml/Logos/Wallet/ +depends QtQuick diff --git a/apps/amm/qml/NavBar.qml b/apps/amm/qml/NavBar.qml index f5b78570..3c06f910 100644 --- a/apps/amm/qml/NavBar.qml +++ b/apps/amm/qml/NavBar.qml @@ -2,8 +2,7 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 import Logos.Theme - -import "components/wallet" +import Logos.Wallet // Self-contained navigation bar — styling is independent of any view's theme. // Use currentIndex to read the active tab; tabChanged(index) fires on selection. @@ -94,11 +93,15 @@ Item { } // Wallet / account control on the far right. - AccountControl { + WalletControl { id: accountControl Layout.leftMargin: 12 - backend: root.backend + wallet: root.backend accountModel: root.accountModel + viewportWidth: root.width + watchCall: function(result, success, failure) { + logos.watch(result, success, failure) + } } } } diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml deleted file mode 100644 index 16b0dc66..00000000 --- a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml +++ /dev/null @@ -1,238 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -FocusScope { - id: root - - property var snapshot: ({}) - property bool open: false - readonly property bool isAdd: root.snapshot.action === "add" - - signal canceled - signal confirmed(var snapshot) - - visible: root.open - z: 20 - - Keys.onEscapePressed: root.cancel() - - function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot; - root.open = true; - root.forceActiveFocus(); - cancelButton.forceActiveFocus(); - } - - function cancel() { - root.open = false; - root.canceled(); - } - - function confirm() { - const confirmedSnapshot = root.snapshot; - root.open = false; - root.confirmed(confirmedSnapshot); - } - - Rectangle { - anchors.fill: parent - color: "#99000000" - - MouseArea { - anchors.fill: parent - } - } - - Rectangle { - id: panel - - anchors.centerIn: parent - color: "#1D1D1D" - implicitHeight: dialogContent.implicitHeight + 24 - radius: 8 - width: Math.max(0, Math.min(360, root.width - 32)) - border.color: "#343434" - border.width: 1 - - ColumnLayout { - id: dialogContent - - anchors.fill: parent - anchors.margins: 12 - spacing: 12 - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 16 - text: root.isAdd ? qsTr("Confirm add liquidity") : qsTr("Confirm remove liquidity") - - Layout.fillWidth: true - } - - ColumnLayout { - spacing: 8 - visible: root.isAdd - - Layout.fillWidth: true - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.depositA || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.depositB || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least") - value: root.snapshot.minLpReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Current ratio") - value: root.snapshot.currentRatio || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Fee tier") - value: root.snapshot.feeTier || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - - Layout.fillWidth: true - } - } - - ColumnLayout { - spacing: 8 - visible: !root.isAdd - - Layout.fillWidth: true - - SummaryRow { - label: qsTr("Burn LP") - value: qsTr("%1 (%2)").arg(root.snapshot.burnText || "").arg(root.snapshot.burnPercent || "") - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.minTokenAReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.minTokenBReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Post-removal share") - value: root.snapshot.postRemovalShare || "" - - Layout.fillWidth: true - } - } - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Button { - id: cancelButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Cancel") - - Accessible.name: cancelButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.cancel() - - contentItem: Text { - color: cancelButton.hovered || cancelButton.activeFocus ? "#151515" : "#E7E1D8" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: cancelButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: cancelButton.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: cancelButton.pressed ? "#343434" : cancelButton.hovered || cancelButton.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: confirmButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Confirm") - - Accessible.name: confirmButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.confirm() - - contentItem: Text { - color: "#151515" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: "#F26A21" - border.width: 1 - color: confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" - radius: 6 - } - } - } - } - } -} diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml new file mode 100644 index 00000000..68af40da --- /dev/null +++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml @@ -0,0 +1,91 @@ +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property var snapshot: ({}) + readonly property bool isAdd: root.snapshot.action === "add" + + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + visible: root.isAdd + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") + value: root.snapshot.depositA || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") + value: root.snapshot.depositB || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Receive at least") + value: root.snapshot.minLpReceived || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Current ratio") + value: root.snapshot.currentRatio || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Fee tier") + value: root.snapshot.feeTier || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Slippage tolerance") + value: root.snapshot.slippageTolerance || "" + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + visible: !root.isAdd + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Burn LP") + value: qsTr("%1 (%2)") + .arg(root.snapshot.burnText || "") + .arg(root.snapshot.burnPercent || "") + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") + value: root.snapshot.minTokenAReceived || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") + value: root.snapshot.minTokenBReceived || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Slippage tolerance") + value: root.snapshot.slippageTolerance || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Post-removal share") + value: root.snapshot.postRemovalShare || "" + } + } +} diff --git a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml b/apps/amm/qml/components/swap/SwapConfirmationDialog.qml deleted file mode 100644 index 54f61b44..00000000 --- a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml +++ /dev/null @@ -1,225 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -FocusScope { - id: root - - property var theme - property var snapshot: ({}) - property bool open: false - - signal canceled - signal confirmed(var snapshot) - - visible: root.open - z: 20 - - Keys.onEscapePressed: root.cancel() - - function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot; - root.open = true; - root.forceActiveFocus(); - cancelButton.forceActiveFocus(); - } - - function cancel() { - root.open = false; - root.canceled(); - } - - function confirm() { - const confirmedSnapshot = root.snapshot; - root.open = false; - root.confirmed(confirmedSnapshot); - } - - Rectangle { - anchors.fill: parent - color: "#99000000" - - MouseArea { - anchors.fill: parent - } - } - - Rectangle { - id: panel - - anchors.centerIn: parent - color: root.theme.colors.cardBg - implicitHeight: dialogContent.implicitHeight + 32 - radius: 16 - width: Math.max(0, Math.min(380, root.width - 32)) - border.color: root.theme.colors.border - border.width: 1 - - MouseArea { - anchors.fill: parent - } - - ColumnLayout { - id: dialogContent - - anchors.fill: parent - anchors.margins: 16 - spacing: 14 - - Text { - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 17 - text: qsTr("Confirm swap") - Layout.fillWidth: true - } - - ColumnLayout { - spacing: 10 - Layout.fillWidth: true - - Rectangle { - Layout.fillWidth: true - color: root.theme.colors.inputBg - radius: 12 - implicitHeight: payColumn.implicitHeight + 24 - - ColumnLayout { - id: payColumn - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Text { - text: qsTr("You pay") - color: root.theme.colors.textSecondary - font.pixelSize: 12 - Layout.fillWidth: true - } - Text { - text: qsTr("%1 %2") - .arg(root.snapshot.sellAmount || "") - .arg(root.snapshot.sellToken || "") - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 18 - elide: Text.ElideRight - Layout.fillWidth: true - } - } - } - - Rectangle { - Layout.fillWidth: true - color: root.theme.colors.inputBg - radius: 12 - implicitHeight: receiveColumn.implicitHeight + 24 - - ColumnLayout { - id: receiveColumn - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Text { - text: qsTr("You receive at least") - color: root.theme.colors.textSecondary - font.pixelSize: 12 - Layout.fillWidth: true - } - Text { - text: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 18 - elide: Text.ElideRight - Layout.fillWidth: true - } - } - } - } - - SwapSummary { - Layout.fillWidth: true - theme: root.theme - swapModeText: root.snapshot.swapModeText || "" - feeText: root.snapshot.feeAmount || "" - priceImpactText: root.snapshot.priceImpactPercent || "" - priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 - slippageText: root.snapshot.slippageTolerance || "" - minReceivedText: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") - } - - RowLayout { - spacing: 10 - Layout.fillWidth: true - Layout.topMargin: 4 - - Button { - id: cancelButton - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Cancel") - Layout.fillWidth: true - Layout.minimumHeight: 48 - onClicked: root.cancel() - - contentItem: Text { - color: root.theme.colors.textPrimary - elide: Text.ElideRight - font.bold: true - font.pixelSize: 14 - horizontalAlignment: Text.AlignHCenter - text: cancelButton.text - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - border.color: root.theme.colors.borderStrong - border.width: 1 - color: cancelButton.pressed - ? root.theme.colors.panelHoverBg - : cancelButton.hovered || cancelButton.activeFocus - ? root.theme.colors.panelBg - : "transparent" - radius: 14 - } - } - - Button { - id: confirmButton - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Confirm Swap") - Layout.fillWidth: true - Layout.minimumHeight: 48 - onClicked: root.confirm() - - contentItem: Text { - color: "#ffffff" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 14 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - border.width: 0 - color: confirmButton.pressed - ? "#D95C1E" - : confirmButton.hovered || confirmButton.activeFocus - ? root.theme.colors.ctaHoverBg - : root.theme.colors.ctaBg - radius: 14 - } - } - } - } - } -} diff --git a/apps/amm/qml/components/swap/SwapConfirmationSummary.qml b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml new file mode 100644 index 00000000..c3ff22e4 --- /dev/null +++ b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property var theme + property var snapshot: ({}) + + spacing: 10 + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 8 + implicitHeight: payColumn.implicitHeight + 24 + + ColumnLayout { + id: payColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + Layout.fillWidth: true + text: qsTr("You pay") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + Layout.fillWidth: true + text: qsTr("%1 %2") + .arg(root.snapshot.sellAmount || "") + .arg(root.snapshot.sellToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + } + } + } + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 8 + implicitHeight: receiveColumn.implicitHeight + 24 + + ColumnLayout { + id: receiveColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + Layout.fillWidth: true + text: qsTr("You receive at least") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + Layout.fillWidth: true + text: qsTr("%1 %2") + .arg(root.snapshot.minReceived || "") + .arg(root.snapshot.buyToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + } + } + } + + SwapSummary { + Layout.fillWidth: true + theme: root.theme + swapModeText: root.snapshot.swapModeText || "" + feeText: root.snapshot.feeAmount || "" + priceImpactText: root.snapshot.priceImpactPercent || "" + priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 + slippageText: root.snapshot.slippageTolerance || "" + minReceivedText: qsTr("%1 %2") + .arg(root.snapshot.minReceived || "") + .arg(root.snapshot.buyToken || "") + } +} diff --git a/apps/amm/qml/components/wallet/AccountControl.qml b/apps/amm/qml/components/wallet/AccountControl.qml deleted file mode 100644 index 9956eebd..00000000 --- a/apps/amm/qml/components/wallet/AccountControl.qml +++ /dev/null @@ -1,490 +0,0 @@ -import QtQuick -import QtQml -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Header wallet control (Uniswap-style), with two states: -// - not connected → a "Connect" button that opens the create-wallet modal -// - connected → a single button showing the active account address; -// clicking it opens a popup (top-right, just under the -// button) holding the account selector, create-account and -// disconnect actions. -// The selected account address is exposed via selectedAddress for the -// trade/liquidity flows to use as the "from" account. -Item { - id: root - - // Backend replica (logos.module("amm_ui")) and its account model. - property var backend: null - property var accountModel: null - - readonly property bool connected: backend !== null && backend.isWalletOpen - - // Index of the active account. selectedAddress/selectedName are derived from - // the model mirror below so they stay valid while the popup (and its list) - // is closed. - property int selectedIndex: 0 - - // Non-visual mirror of the account model: realizes every row regardless of - // popup visibility, so the active account is addressable by index at all - // times (a ListView only realizes rows while it is shown). - Instantiator { - id: accounts - model: root.accountModel - delegate: QtObject { - readonly property string address: model.address ?? "" - readonly property string name: model.name ?? "" - readonly property string balance: model.balance ?? "" - readonly property bool isPublic: model.isPublic ?? false - } - } - - function entryAt(i) { - return (i >= 0 && i < accounts.count) ? accounts.objectAt(i) : null - } - - readonly property string selectedAddress: { - const e = root.entryAt(root.selectedIndex) - return e ? e.address : "" - } - readonly property string selectedName: { - const e = root.entryAt(root.selectedIndex) - return e ? e.name : "" - } - readonly property string selectedBalance: { - const e = root.entryAt(root.selectedIndex) - return e ? e.balance : "" - } - readonly property bool selectedIsPublic: { - const e = root.entryAt(root.selectedIndex) - return e ? e.isPublic : false - } - - // Keep the selection within bounds as accounts are added/removed. - function clampSelection() { - if (accounts.count === 0) { root.selectedIndex = 0; return } - if (root.selectedIndex < 0) root.selectedIndex = 0 - else if (root.selectedIndex >= accounts.count) root.selectedIndex = accounts.count - 1 - } - Connections { - target: root.accountModel - ignoreUnknownSignals: true - function onModelReset() { root.clampSelection() } - function onRowsInserted() { root.clampSelection() } - function onRowsRemoved() { root.clampSelection() } - } - - // 0x123456…cdef style truncation for the connected button label. - function truncated(addr) { - if (!addr) return "" - return addr.length > 13 ? (addr.substring(0, 6) + "…" + addr.substring(addr.length - 4)) : addr - } - - // Copy on the QML/view side. Routing this through the backend would call - // QGuiApplication::clipboard() in the (headless) module host process, which - // has no clipboard — that call tears the backend down, dropping the wallet - // connection. A hidden TextEdit copies via the GUI process that owns it. - TextEdit { id: clipboardProxy; visible: false } - function copyToClipboard(text) { - if (!text) return - clipboardProxy.text = text - clipboardProxy.selectAll() - clipboardProxy.copy() - clipboardProxy.deselect() - clipboardProxy.text = "" - } - - implicitWidth: root.connected ? connectedButton.width : connectButton.width - implicitHeight: 40 - - // ── Disconnected: Connect ──────────────────────────────────────────── - LogosButton { - id: connectButton - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - height: 40 - visible: !root.connected - enabled: root.backend !== null - text: qsTr("Connect") - onClicked: { - // Re-open an existing wallet; only show the create modal on first run. - if (root.backend && root.backend.walletExists) - logos.watch(root.backend.openExisting(), - function(ok) { if (!ok) console.warn("openExisting failed") }, - function(error) { console.warn("openExisting error:", error) }) - else - createWalletDialog.open() - } - } - - // ── Connected: address pill that toggles the wallet menu ───────────── - Rectangle { - id: connectedButton - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - visible: root.connected - implicitHeight: 40 - implicitWidth: connectedRow.implicitWidth + Theme.spacing.medium * 2 - radius: height / 2 - // Keep an opaque dark fill in both states: the navbar is white, and the - // active "muted" fill is translucent gray, which renders light over white - // and makes the white label unreadable. Signal "open" with an accent - // border instead. - color: Theme.palette.backgroundSecondary - border.width: 1 - border.color: walletMenu.opened ? Theme.palette.overlayOrange : "transparent" - - RowLayout { - id: connectedRow - anchors.centerIn: parent - spacing: Theme.spacing.small - - Rectangle { - Layout.preferredWidth: 8 - Layout.preferredHeight: 8 - radius: 4 - color: "#39c06a" - } - LogosText { - text: root.truncated(root.selectedAddress) || qsTr("Connected") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.text - } - LogosText { - text: walletMenu.opened ? "▴" : "▾" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - // CloseOnPressOutside already dismisses the popup on this same press - // (the button is outside it), so `opened` is false by the time this - // fires. Without the recency guard the dismissing click would just - // reopen it. If it just closed, leave it closed. - onClicked: { - if (walletMenu.opened || (Date.now() - walletMenu.lastClosedMs) < 200) - walletMenu.close() - else - walletMenu.open() - } - } - } - - // ── Wallet menu popup (top-right, under the connected button) ───────── - Popup { - id: walletMenu - parent: connectedButton - y: connectedButton.height + Theme.spacing.small - x: connectedButton.width - width // right-align under the button - width: 360 - padding: Theme.spacing.medium - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - // Timestamp of the last dismissal, used by the toggle button to tell a - // genuine "open" click from the press that just closed the popup. - property real lastClosedMs: 0 - onClosed: { - walletMenu.lastClosedMs = Date.now() - // Always reopen on the main (selected-account) view. - if (viewStack.depth > 1) - viewStack.pop(null, StackView.Immediate) - } - - background: Rectangle { - color: Theme.palette.backgroundTertiary - border.width: 1 - border.color: Theme.palette.backgroundElevated - radius: Theme.spacing.radiusLarge - } - - // Two stacked views: the main view (active account + actions) and the - // accounts view (full list + create). The popup height follows the - // active view's natural height, animated so the resize isn't abrupt. - contentItem: StackView { - id: viewStack - clip: true - implicitWidth: walletMenu.availableWidth - implicitHeight: currentItem ? currentItem.implicitHeight : 0 - initialItem: mainView - - Behavior on implicitHeight { - NumberAnimation { duration: 160; easing.type: Easing.OutCubic } - } - - pushEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } } - pushExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } } - popEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } } - popExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } } - } - - // ── Main view: account icon + power icon, then the active account ── - Component { - id: mainView - - ColumnLayout { - spacing: Theme.spacing.medium - - // Top-right actions: open the account list / disconnect. - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - Item { Layout.fillWidth: true } - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/account.svg") - onClicked: viewStack.push(accountsView) - } - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/settings.svg") - onClicked: viewStack.push(settingsView) - } - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/power.svg") - onClicked: { - walletMenu.close() - if (root.backend) root.backend.disconnectWallet() - } - } - } - - // Active account card. - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: cardColumn.implicitHeight + Theme.spacing.medium * 2 - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundMuted - - ColumnLayout { - id: cardColumn - anchors.fill: parent - anchors.margins: Theme.spacing.medium - spacing: Theme.spacing.small - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - LogosText { - text: root.selectedName - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - Rectangle { - Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2 - Layout.preferredHeight: tagLabel.implicitHeight + 4 - radius: 4 - color: Theme.palette.backgroundSecondary - LogosText { - id: tagLabel - anchors.centerIn: parent - text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - Item { Layout.fillWidth: true } - LogosText { - text: root.selectedBalance.length > 0 ? root.selectedBalance : "—" - font.bold: true - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 0 - LogosText { - Layout.fillWidth: true - verticalAlignment: Text.AlignVCenter - text: root.selectedAddress - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textMuted - elide: Text.ElideMiddle - } - LogosCopyButton { - Layout.preferredHeight: 40 - Layout.preferredWidth: 40 - visible: root.selectedAddress.length > 0 - onCopyText: root.copyToClipboard(root.selectedAddress) - icon.color: Theme.palette.textMuted - } - } - } - } - } - } - - // ── Accounts view: back + full list + create ────────────────────── - Component { - id: accountsView - - ColumnLayout { - spacing: Theme.spacing.medium - - // Header: back to the main view + title. - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/back.svg") - onClicked: viewStack.pop() - } - LogosText { - Layout.fillWidth: true - text: qsTr("Accounts") - font.bold: true - color: Theme.palette.text - } - } - - // Account list: tap a row to make it the active account, then - // return to the main view so the selection is reflected. - ListView { - Layout.fillWidth: true - Layout.preferredHeight: Math.min(contentHeight, 260) - clip: true - model: root.accountModel - spacing: Theme.spacing.small - ScrollIndicator.vertical: ScrollIndicator { } - - delegate: AccountDelegate { - width: ListView.view.width - highlighted: index === root.selectedIndex - onClicked: { - root.selectedIndex = index - viewStack.pop() - } - onCopyRequested: (text) => root.copyToClipboard(text) - } - } - - LogosButton { - Layout.fillWidth: true - height: 40 - text: qsTr("Add") - // Leave the wallet menu open behind the (modal) dialog. - onClicked: createAccountDialog.open() - } - } - } - - // ── Settings view: back + editable network (sequencer) ──────────── - Component { - id: settingsView - - ColumnLayout { - spacing: Theme.spacing.medium - - // Header: back to the main view + title. - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/back.svg") - onClicked: viewStack.pop() - } - LogosText { - Layout.fillWidth: true - text: qsTr("Settings") - font.bold: true - color: Theme.palette.text - } - } - - LogosText { - text: qsTr("Network (sequencer URL)") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - - LogosTextField { - id: seqField - Layout.fillWidth: true - placeholderText: "http://127.0.0.1:3040" - // Initialize from the live value without binding, so typing - // isn't clobbered when sequencerAddr updates after a save. - Component.onCompleted: text = root.backend ? root.backend.sequencerAddr : "" - } - - LogosText { - id: seqStatus - Layout.fillWidth: true - visible: text.length > 0 - wrapMode: Text.WordWrap - font.pixelSize: Theme.typography.secondaryText - property bool ok: false - color: ok ? Theme.palette.success : Theme.palette.error - } - - LogosButton { - Layout.fillWidth: true - height: 40 - text: qsTr("Save") - onClicked: { - if (!root.backend) return - seqStatus.text = "" - logos.watch(root.backend.changeSequencerAddr(seqField.text), - function(ok) { - seqStatus.ok = ok - seqStatus.text = ok ? qsTr("Network updated.") - : qsTr("Failed to update network.") - }, - function(error) { - seqStatus.ok = false - seqStatus.text = qsTr("Error: %1").arg(error) - }) - } - } - } - } - } - - // ── Dialogs ────────────────────────────────────────────────────────── - CreateWalletDialog { - id: createWalletDialog - walletHome: root.backend ? root.backend.walletHome : "" - onCreateWallet: function(password) { - if (!root.backend) return - // createNewDefault returns the new wallet's seed phrase (empty on - // failure). On success we hand it to the dialog, which switches to - // its backup page — we do NOT close here, so the user can't skip it. - logos.watch(root.backend.createNewDefault(password), - function(mnemonic) { - if (mnemonic && mnemonic.length > 0) - createWalletDialog.mnemonic = mnemonic - else - createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.") - }, - function(error) { - createWalletDialog.createError = qsTr("Error creating wallet: %1").arg(error) - }) - } - onCopyRequested: function(text) { - if (root.backend) root.backend.copyToClipboard(text) - } - } - - CreateAccountDialog { - id: createAccountDialog - onCreatePublicRequested: { - if (!root.backend) return - logos.watch(root.backend.createAccountPublic(), - function(_id) { /* model updates via NOTIFY after refresh */ }, - function(error) { console.warn("createAccountPublic failed:", error) }) - } - onCreatePrivateRequested: { - if (!root.backend) return - logos.watch(root.backend.createAccountPrivate(), - function(_id) { /* model updates via NOTIFY after refresh */ }, - function(error) { console.warn("createAccountPrivate failed:", error) }) - } - } -} diff --git a/apps/amm/qml/components/wallet/AccountDelegate.qml b/apps/amm/qml/components/wallet/AccountDelegate.qml deleted file mode 100644 index 14c03ee2..00000000 --- a/apps/amm/qml/components/wallet/AccountDelegate.qml +++ /dev/null @@ -1,84 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// One account row in the account dropdown. Ported from the LEZ wallet UI. -ItemDelegate { - id: root - - // Emitted when the user clicks the copy icon; the parent view connects this - // to its QML-side clipboard helper (AccountControl.copyToClipboard). - signal copyRequested(string text) - - leftPadding: Theme.spacing.medium - rightPadding: Theme.spacing.medium - topPadding: Theme.spacing.medium - bottomPadding: Theme.spacing.medium - - background: Rectangle { - color: root.highlighted || root.hovered ? - Theme.palette.backgroundMuted : - Theme.palette.backgroundTertiary - radius: Theme.spacing.radiusLarge - } - - contentItem: ColumnLayout { - spacing: Theme.spacing.small - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - LogosText { - text: model.name ?? "" - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - - Rectangle { - Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2 - Layout.preferredHeight: tagLabel.implicitHeight + 4 - radius: 4 - color: Theme.palette.backgroundSecondary - - LogosText { - id: tagLabel - anchors.centerIn: parent - text: model.isPublic ? qsTr("Public") : qsTr("Private") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - - Item { Layout.fillWidth: true } - - LogosText { - text: model.balance && model.balance.length > 0 ? model.balance : "—" - font.bold: true - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 0 - LogosText { - id: addressLabel - Layout.fillWidth: true - verticalAlignment: Text.AlignVCenter - text: model.address ?? "" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textMuted - elide: Text.ElideMiddle - } - LogosCopyButton { - Layout.preferredHeight: 40 - Layout.preferredWidth: 40 - onCopyText: root.copyRequested(model.address) - visible: addressLabel.text - icon.color: Theme.palette.textMuted - } - } - } -} diff --git a/apps/amm/qml/components/wallet/CreateAccountDialog.qml b/apps/amm/qml/components/wallet/CreateAccountDialog.qml deleted file mode 100644 index 80707958..00000000 --- a/apps/amm/qml/components/wallet/CreateAccountDialog.qml +++ /dev/null @@ -1,102 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Public/private account creation dialog. Ported from the LEZ wallet UI. -Popup { - id: root - - signal createPublicRequested() - signal createPrivateRequested() - - modal: true - dim: true - padding: Theme.spacing.large - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - // Center on the full-window overlay rather than the small navbar control - // this popup is declared inside. - parent: Overlay.overlay - anchors.centerIn: parent - width: 360 - - background: Rectangle { - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusXlarge - border.color: Theme.palette.backgroundElevated - } - - contentItem: ColumnLayout { - id: contentLayout - // Pin to the popup's padded width so children stay within the modal. - width: root.availableWidth - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Create account") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Choose account type.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - Layout.topMargin: -Theme.spacing.small - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.medium - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - LogosText { - text: qsTr("Private") - font.pixelSize: Theme.typography.primaryText - color: Theme.palette.text - } - LogosText { - text: qsTr("Private balance and activity.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - } - } - - LogosSwitch { - id: privateSwitch - checked: false - } - } - - RowLayout { - Layout.topMargin: Theme.spacing.medium - spacing: Theme.spacing.medium - Layout.fillWidth: true - LogosButton { - text: qsTr("Cancel") - Layout.fillWidth: true - onClicked: root.close() - } - LogosButton { - text: qsTr("Create") - Layout.fillWidth: true - onClicked: { - if (privateSwitch.checked) - root.createPrivateRequested() - else - root.createPublicRequested() - root.close() - } - } - } - } -} diff --git a/apps/amm/qml/components/wallet/CreateWalletDialog.qml b/apps/amm/qml/components/wallet/CreateWalletDialog.qml deleted file mode 100644 index 05c8d6e6..00000000 --- a/apps/amm/qml/components/wallet/CreateWalletDialog.qml +++ /dev/null @@ -1,201 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Wallet creation modal. Two pages, driven by whether a mnemonic exists yet: -// 1. Password entry — emits createWallet(password); the parent creates the -// wallet and, on success, sets `mnemonic` to the returned seed phrase. -// 2. Seed-phrase backup — shows the mnemonic once and gates dismissal behind -// an explicit acknowledgement. This is the only time the phrase is shown, -// so the popup is not auto-dismissable while it is visible. -// Storage/config live at the per-app default (backend.walletHome) — no path -// picking. Opened from the navbar "Connect" button. -Popup { - id: root - - // Where the wallet will be stored, shown for transparency. - property string walletHome: "" - property string createError: "" - // Set by the parent to the BIP39 seed phrase once creation succeeds. A - // non-empty value flips the dialog to the backup page. - property string mnemonic: "" - - signal createWallet(string password) - signal copyRequested(string text) - - modal: true - dim: true - padding: Theme.spacing.large - // Once the wallet exists we must not let the user dismiss the modal (and - // lose the only view of their seed phrase) by clicking away or pressing Esc. - closePolicy: root.mnemonic.length > 0 - ? Popup.NoAutoClose - : (Popup.CloseOnEscape | Popup.CloseOnPressOutside) - // Center on the full-window overlay rather than the small navbar control - // this popup is declared inside. - parent: Overlay.overlay - anchors.centerIn: parent - width: 380 - - onOpened: { - passwordField.text = "" - confirmField.text = "" - root.createError = "" - root.mnemonic = "" - passwordField.forceActiveFocus() - } - - background: Rectangle { - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusXlarge - border.color: Theme.palette.backgroundElevated - } - - contentItem: ColumnLayout { - // Pin to the popup's padded width so long text wraps and fillWidth - // children don't push the layout wider than the modal. - width: root.availableWidth - spacing: 0 - - // ── Page 1: password entry ──────────────────────────────────────── - ColumnLayout { - id: passwordPage - visible: root.mnemonic.length === 0 - Layout.fillWidth: true - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Create your wallet") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Secure your wallet with a password. It will be stored on this device at %1.") - .arg(root.walletHome || qsTr("the default location")) - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - Layout.topMargin: -Theme.spacing.small - } - - LogosTextField { - id: passwordField - Layout.fillWidth: true - placeholderText: qsTr("Password") - echoMode: TextInput.Password - Keys.onReturnPressed: createButton.tryCreate() - } - LogosTextField { - id: confirmField - Layout.fillWidth: true - placeholderText: qsTr("Confirm password") - echoMode: TextInput.Password - Keys.onReturnPressed: createButton.tryCreate() - } - - LogosText { - Layout.fillWidth: true - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.error - wrapMode: Text.WordWrap - visible: text.length > 0 - text: root.createError - } - - RowLayout { - Layout.topMargin: Theme.spacing.small - Layout.fillWidth: true - spacing: Theme.spacing.medium - LogosButton { - text: qsTr("Cancel") - Layout.fillWidth: true - onClicked: root.close() - } - LogosButton { - id: createButton - Layout.fillWidth: true - text: qsTr("Create Wallet") - function tryCreate() { - if (passwordField.text.length === 0) { - root.createError = qsTr("Password cannot be empty.") - } else if (passwordField.text !== confirmField.text) { - root.createError = qsTr("Passwords do not match.") - } else { - root.createError = "" - root.createWallet(passwordField.text) - } - } - onClicked: tryCreate() - } - } - } - - // ── Page 2: seed-phrase backup ──────────────────────────────────── - ColumnLayout { - id: backupPage - visible: root.mnemonic.length > 0 - Layout.fillWidth: true - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Back up your recovery phrase") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Write these words down in order and store them somewhere safe. Anyone with this phrase can control your wallet, and it will not be shown again — it is the only way to recover access.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - Layout.topMargin: -Theme.spacing.small - } - - Rectangle { - Layout.fillWidth: true - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundElevated - implicitHeight: phraseText.implicitHeight + 2 * Theme.spacing.medium - - LogosText { - id: phraseText - anchors.fill: parent - anchors.margins: Theme.spacing.medium - text: root.mnemonic - wrapMode: Text.WordWrap - lineHeight: 1.4 - font.pixelSize: Theme.typography.primaryText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - } - - LogosButton { - Layout.fillWidth: true - text: qsTr("Copy to clipboard") - onClicked: root.copyRequested(root.mnemonic) - } - - LogosCheckbox { - id: ackCheck - Layout.fillWidth: true - text: qsTr("I have safely backed up my recovery phrase") - } - - LogosButton { - Layout.fillWidth: true - enabled: ackCheck.checked - text: qsTr("Continue") - onClicked: root.close() - } - } - } -} diff --git a/apps/amm/qml/components/wallet/LogosCopyButton.qml b/apps/amm/qml/components/wallet/LogosCopyButton.qml deleted file mode 100644 index 7c1d7bdc..00000000 --- a/apps/amm/qml/components/wallet/LogosCopyButton.qml +++ /dev/null @@ -1,39 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import Logos.Theme - -Button { - id: root - - signal copyText() - - implicitWidth: 24 - implicitHeight: 24 - display: AbstractButton.IconOnly - flat: true - - property string iconSource: Qt.resolvedUrl("icons/copy.svg") - - icon.source: root.iconSource - icon.width: 24 - icon.height: 24 - icon.color: Theme.palette.textSecondary - - function reset() { - iconSource = Qt.resolvedUrl("icons/copy.svg") - } - - Timer { - id: resetTimer - interval: 1500 - repeat: false - onTriggered: root.reset() - } - - onClicked: { - root.copyText() - root.iconSource = Qt.resolvedUrl("icons/checkmark.svg") - resetTimer.restart() - } -} diff --git a/apps/amm/qml/components/wallet/WalletIconButton.qml b/apps/amm/qml/components/wallet/WalletIconButton.qml deleted file mode 100644 index 61545e0d..00000000 --- a/apps/amm/qml/components/wallet/WalletIconButton.qml +++ /dev/null @@ -1,25 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import Logos.Theme - -// Small icon-only action button for the wallet menu. Uses the same Button + -// icon.source/icon.color path as LogosCopyButton, which renders reliably here -// (LogosIconButton's Image + MultiEffect shader path does not). -Button { - id: root - - property url iconSource - property color iconColor: Theme.palette.textSecondary - property int iconSize: 18 - - implicitWidth: 32 - implicitHeight: 32 - display: AbstractButton.IconOnly - flat: true - - icon.source: root.iconSource - icon.width: root.iconSize - icon.height: root.iconSize - icon.color: root.iconColor -} diff --git a/apps/amm/qml/components/wallet/icons/account.svg b/apps/amm/qml/components/wallet/icons/account.svg deleted file mode 100644 index 779d76ac..00000000 --- a/apps/amm/qml/components/wallet/icons/account.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/amm/qml/components/wallet/icons/back.svg b/apps/amm/qml/components/wallet/icons/back.svg deleted file mode 100644 index 69608756..00000000 --- a/apps/amm/qml/components/wallet/icons/back.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/amm/qml/components/wallet/icons/checkmark.svg b/apps/amm/qml/components/wallet/icons/checkmark.svg deleted file mode 100644 index 2eabb54f..00000000 --- a/apps/amm/qml/components/wallet/icons/checkmark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/amm/qml/components/wallet/icons/copy.svg b/apps/amm/qml/components/wallet/icons/copy.svg deleted file mode 100644 index cfa6cf8b..00000000 --- a/apps/amm/qml/components/wallet/icons/copy.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/amm/qml/components/wallet/icons/power.svg b/apps/amm/qml/components/wallet/icons/power.svg deleted file mode 100644 index 5900ca36..00000000 --- a/apps/amm/qml/components/wallet/icons/power.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/amm/qml/components/wallet/icons/settings.svg b/apps/amm/qml/components/wallet/icons/settings.svg deleted file mode 100644 index 77f89ae5..00000000 --- a/apps/amm/qml/components/wallet/icons/settings.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index f4719521..9dde13a6 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -1,5 +1,6 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 +import Logos.Wallet import "../components/shared" import "../components/liquidity" import "../state" @@ -162,10 +163,18 @@ Item { } } - LiquidityConfirmationDialog { - id: confirmationDialog + Component { + id: liquidityConfirmationSummary - anchors.fill: parent + LiquidityConfirmationSummary { } + } + + TransactionConfirmationDialog { + id: confirmationDialog + title: snapshot.action === "add" + ? qsTr("Confirm add liquidity") + : qsTr("Confirm remove liquidity") + summary: liquidityConfirmationSummary onConfirmed: function (snapshot) { root.confirmLiquidityAction(snapshot); diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 75b42d4e..f4aca1a2 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -1,9 +1,10 @@ +pragma ComponentBehavior: Bound + import QtQuick 2.15 -import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import Logos.Wallet import "../components/shared" import "../components/swap" -import "../state" Item { id: root @@ -18,7 +19,7 @@ Item { ] QtObject { - id: theme + id: pageTheme property bool isDark: true property var colors: isDark ? dark : light @@ -61,7 +62,7 @@ Item { Rectangle { anchors.fill: parent - color: theme.colors.background + color: pageTheme.colors.background Behavior on color { ColorAnimation { duration: 300 } } // Theme toggle @@ -70,19 +71,19 @@ Item { anchors.right: parent.right anchors.margins: 16 width: 44; height: 24; radius: 12 - color: theme.colors.panelBg - border.color: theme.colors.border + color: pageTheme.colors.panelBg + border.color: pageTheme.colors.border border.width: 1 Text { anchors.centerIn: parent - text: theme.isDark ? "☀" : "☾" + text: pageTheme.isDark ? "☀" : "☾" font.pixelSize: 13 - color: theme.colors.textSecondary + color: pageTheme.colors.textSecondary } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: theme.isDark = !theme.isDark + onClicked: pageTheme.isDark = !pageTheme.isDark } } @@ -93,7 +94,7 @@ Item { SwapCard { id: swapCard Layout.alignment: Qt.AlignHCenter - theme: theme + theme: pageTheme tokens: root.tokens width: Math.min(480, root.width - 32) @@ -109,9 +110,9 @@ Item { Text { Layout.alignment: Qt.AlignHCenter - text: "Buy and sell crypto on LEZ." + text: "Buy and sell crypto on LEZ." textFormat: Text.RichText - color: theme.colors.textSecondary + color: pageTheme.colors.textSecondary font.pixelSize: 15 horizontalAlignment: Text.AlignHCenter } @@ -121,7 +122,7 @@ Item { id: tokenModal anchors.fill: parent z: 10 - theme: theme + theme: pageTheme tokens: root.tokens property string targetSide: "sell" @@ -144,10 +145,19 @@ Item { } } - SwapConfirmationDialog { + Component { + id: swapConfirmationSummary + + SwapConfirmationSummary { + theme: pageTheme + } + } + + TransactionConfirmationDialog { id: swapConfirmationDialog - anchors.fill: parent - theme: theme + title: qsTr("Confirm swap") + confirmText: qsTr("Confirm swap") + summary: swapConfirmationSummary onConfirmed: function(snapshot) { swapCard.resetAmounts() diff --git a/apps/amm/src/AccountModel.cpp b/apps/amm/src/AccountModel.cpp deleted file mode 100644 index 63992f86..00000000 --- a/apps/amm/src/AccountModel.cpp +++ /dev/null @@ -1,79 +0,0 @@ -#include "AccountModel.h" - -#include - -AccountModel::AccountModel(QObject* parent) - : QAbstractListModel(parent) -{ -} - -int AccountModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) - return 0; - return m_entries.size(); -} - -QVariant AccountModel::data(const QModelIndex& index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) - return QVariant(); - - const AccountEntry& e = m_entries.at(index.row()); - switch (role) { - case NameRole: return e.name; - case AddressRole: return e.address; - case BalanceRole: return e.balance; - case IsPublicRole: return e.isPublic; - default: return QVariant(); - } -} - -QHash AccountModel::roleNames() const -{ - return { - { NameRole, "name" }, - { AddressRole, "address" }, - { BalanceRole, "balance" }, - { IsPublicRole, "isPublic" } - }; -} - -void AccountModel::replaceFromJsonArray(const QJsonArray& arr) -{ - beginResetModel(); - const int oldCount = m_entries.size(); - m_entries.clear(); - int idx = 0; - for (const QJsonValue& v : arr) { - AccountEntry e; - e.name = QStringLiteral("Account %1").arg(++idx); - e.balance = QString(); - if (v.isObject()) { - const QJsonObject obj = v.toObject(); - e.address = obj.value(QStringLiteral("account_id")).toString(); - e.isPublic = obj.value(QStringLiteral("is_public")).toBool(true); - } else { - e.address = v.toString(); - e.isPublic = true; - } - m_entries.append(e); - } - endResetModel(); - if (oldCount != m_entries.size()) - emit countChanged(); -} - -void AccountModel::setBalanceByAddress(const QString& address, const QString& balance) -{ - for (int i = 0; i < m_entries.size(); ++i) { - if (m_entries.at(i).address == address) { - if (m_entries.at(i).balance != balance) { - m_entries[i].balance = balance; - const QModelIndex idx = index(i, 0); - emit dataChanged(idx, idx, { BalanceRole }); - } - return; - } - } -} diff --git a/apps/amm/src/AccountModel.h b/apps/amm/src/AccountModel.h deleted file mode 100644 index 918839c3..00000000 --- a/apps/amm/src/AccountModel.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -// One wallet account row. Mirrors the shape returned by the core wallet -// module's list_accounts() (account_id + is_public), with a display name and -// a lazily-fetched balance. -struct AccountEntry { - QString name; - QString address; - QString balance; - bool isPublic = true; -}; - -// QAbstractListModel of wallet accounts, exposed to QML via -// logos.model("amm_ui", "accountModel"). Ported from the LEZ wallet UI. -class AccountModel : 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 AccountModel(QObject* parent = nullptr); - - int rowCount(const QModelIndex& parent = QModelIndex()) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - - void replaceFromJsonArray(const QJsonArray& arr); - void setBalanceByAddress(const QString& address, const QString& balance); - int count() const { return m_entries.size(); } - -signals: - void countChanged(); - -private: - QVector m_entries; -}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index f937d039..c2e75a6d 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,15 +1,8 @@ #include "AmmUiBackend.h" -#include -#include #include #include -#include #include -#include -#include -#include -#include #include #include #include @@ -17,27 +10,21 @@ #include #include +#include "LogosWalletProvider.h" #include "logos_api.h" -#include "logos_sdk.h" namespace { - const char SETTINGS_ORG[] = "Logos"; - const char SETTINGS_APP[] = "AmmUI"; - // Sticky "user pressed Disconnect" flag so the wallet stays locked across - // relaunches until the user reconnects. - const char DISCONNECTED_KEY[] = "disconnected"; - const int WALLET_FFI_SUCCESS = 0; +const char SETTINGS_ORG[] = "Logos"; +const char SETTINGS_APP[] = "AmmUI"; +const char DISCONNECTED_KEY[] = "disconnected"; +const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; - // Wallet home env override. Mirrors LEZ's own var so the app shares the - // canonical wallet (~/.lee/wallet) used by the wallet UI and other apps. - const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; - - // Normalise file:// URLs and OS paths to a plain local path. - QString toLocalPath(const QString& path) { - if (path.startsWith("file://") || path.contains("/")) - return QUrl::fromUserInput(path).toLocalFile(); - return path; - } +QString toLocalPath(const QString& path) +{ + if (path.startsWith(QStringLiteral("file://")) || path.contains(QLatin1Char('/'))) + return QUrl::fromUserInput(path).toLocalFile(); + return path; +} } QString AmmUiBackend::defaultWalletHome() @@ -45,8 +32,6 @@ QString AmmUiBackend::defaultWalletHome() const QByteArray override = qgetenv(WALLET_HOME_ENV); if (!override.isEmpty()) return QString::fromLocal8Bit(override); - // LEZ's canonical wallet home, shared with the wallet UI and other LEZ apps - // (matches lez/wallet get_home_default_path()). return QDir::homePath() + QStringLiteral("/.lee/wallet"); } @@ -62,286 +47,196 @@ QString AmmUiBackend::defaultStoragePath() const AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), - m_accountModel(new AccountModel(this)), + m_accountModel(new WalletAccountModel(this)), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), - m_logos(new LogosModules(m_logosAPI)), + m_wallet(std::make_unique(m_logosAPI)), m_net(new QNetworkAccessManager(this)), m_reachabilityTimer(new QTimer(this)) { - // PROP defaults via the generated setters. setIsWalletOpen(false); setLastSyncedBlock(0); setCurrentBlockHeight(0); setWalletHome(defaultWalletHome()); - // Assume reachable until a probe proves otherwise (avoids a startup flash). setSequencerReachable(true); + setWalletExists(QFileInfo::exists(defaultStoragePath())); - // Periodically re-probe the sequencer so the banner reacts to a node going - // up/down while the app is running. Probes are no-ops until a wallet (and - // thus a sequencer address) is open. m_reachabilityTimer->setInterval(10000); - connect(m_reachabilityTimer, &QTimer::timeout, this, [this]() { checkReachability(); }); + connect(m_reachabilityTimer, &QTimer::timeout, + this, &AmmUiBackend::checkReachability); m_reachabilityTimer->start(); - // Always resolve against the canonical wallet home (LEE_WALLET_HOME_DIR or - // ~/.lee/wallet). We intentionally don't seed config/storage paths from - // QSettings anymore: a previously-persisted per-app path (~/.lee/amm-wallet) - // would otherwise override the default and pin the app to the old keystore. - - // A wallet exists on disk if its storage file is present (drives whether - // the navbar "Connect" reconnects or offers to create a wallet). - const QString effectiveStorage = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - setWalletExists(QFileInfo::exists(effectiveStorage)); - - // ui-host runs our constructor inside initLogos(), synchronously, BEFORE - // it enables remoting and emits READY. Any blocking RPC here would stall - // ui-host startup past its ready watchdog. Defer the open+refresh chain to - // the first event-loop tick so ui-host finishes wiring itself up first. - QTimer::singleShot(0, this, [this]() { openOrAdoptWallet(); }); - - // Save wallet on quit; host may not call destructors so this is best-effort. - connect(qApp, &QCoreApplication::aboutToQuit, this, - [this]() { saveWallet(); }, Qt::DirectConnection); + QTimer::singleShot(0, this, &AmmUiBackend::openOrAdoptWallet); } -AmmUiBackend::~AmmUiBackend() -{ - saveWallet(); - delete m_logos; -} +AmmUiBackend::~AmmUiBackend() = default; void AmmUiBackend::openOrAdoptWallet() { - // Respect an explicit user disconnect: stay locked, show "Connect". if (QSettings(SETTINGS_ORG, SETTINGS_APP).value(DISCONNECTED_KEY, false).toBool()) return; - // In Basecamp the logos_execution_zone module is a single shared instance, - // so the wallet may already be open (e.g. opened by the dedicated wallet - // app). Adopt that wallet instead of fighting over it: mirror its state - // rather than re-opening from disk, which could clobber unsaved in-memory - // accounts the other app holds. A freshly-created shared wallet can be open - // with zero accounts, so we can't key off list_accounts() alone (see - // sharedWalletIsOpen). - if (sharedWalletIsOpen()) { - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - qDebug() << "AmmUiBackend: adopting already-open shared wallet" - << existing.size() << "accounts"; - setIsWalletOpen(true); - m_accountModel->replaceFromJsonArray(existing); - refreshBalances(); - refreshSequencerAddr(); + 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() << "AmmUiBackend: wallet connection failed" + << walletFailureCode(session.failure); return; } - // Standalone (own core instance): auto-open a previously-created wallet. - // Use persisted paths if the user picked custom ones, else the per-app - // default. Only open if the storage actually exists, otherwise stay closed - // so QML shows the "Connect" entry point (no noisy FFI errors on first run). - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - if (!QFileInfo::exists(stg)) - return; // No wallet yet — QML shows "Connect". - - qDebug() << "AmmUiBackend: opening wallet with config" << cfg << "storage" << stg; - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err == WALLET_FFI_SUCCESS) { - persistConfigPath(cfg); - persistStoragePath(stg); - setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - } else { - qWarning() << "AmmUiBackend: wallet open failed, code" << err; - } -} - -bool AmmUiBackend::sharedWalletIsOpen() -{ - // list_accounts() is non-empty only once the wallet holds accounts, so it - // can't distinguish "no wallet open" from "open but empty" (a wallet that - // was just created and hasn't had an account added yet). Fall back to a - // handle-dependent, account-independent signal: an open wallet always has a - // sequencer address (from its config, defaulted on open), while a closed - // core returns an empty string. This lets us adopt a freshly-created shared - // wallet instead of falling through and re-opening it from disk. - if (!QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()).isEmpty()) - return true; - return !m_logos->logos_execution_zone.get_sequencer_addr().isEmpty(); + persistConfigPath(config); + persistStoragePath(storage); + setWalletExists(QFileInfo::exists(storage) || session.adopted); + setIsWalletOpen(true); + applySnapshot(session.snapshot); } QString AmmUiBackend::createNewDefault(QString password) { - QDir().mkpath(defaultWalletHome()); return createNew(defaultConfigPath(), defaultStoragePath(), password); } -QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password) +QString AmmUiBackend::createNew(QString configPath, + QString storagePath, + QString password) { - const QString localConfig = toLocalPath(configPath); - const QString localStorage = toLocalPath(storagePath); - // create_new returns the new wallet's BIP39 mnemonic (empty on failure). We - // hand it back to the caller instead of discarding it: wallet creation is - // the only moment the seed phrase is recoverable, so the UI must force a - // backup step before the user can proceed. - const QString mnemonic = m_logos->logos_execution_zone.create_new(localConfig, localStorage, password); - if (mnemonic.isEmpty()) { - qWarning() << "AmmUiBackend: create_new failed (empty mnemonic)"; - return QString(); + const QString config = toLocalPath(configPath); + const QString storage = toLocalPath(storagePath); + const WalletCreation creation = m_wallet->createWallet( + { config, storage }, password); + if (creation.mnemonic.isEmpty()) { + qWarning() << "AmmUiBackend: wallet creation failed" + << walletFailureCode(creation.failure); + return {}; } - persistConfigPath(localConfig); - persistStoragePath(localStorage); + persistConfigPath(config); + persistStoragePath(storage); setWalletExists(true); QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); + if (!creation.ok()) { + qWarning() << "AmmUiBackend: wallet creation failed" + << walletFailureCode(creation.failure); + return creation.mnemonic; + } + setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - return mnemonic; + applySnapshot(creation.snapshot); + return creation.mnemonic; } bool AmmUiBackend::openExisting() { - // Adopt a shared open wallet (Basecamp), else open our own from disk. A - // freshly-created shared wallet can be open with zero accounts, so probe - // open-ness rather than keying off list_accounts() alone. - if (sharedWalletIsOpen()) { - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - setIsWalletOpen(true); - m_accountModel->replaceFromJsonArray(existing); - refreshBalances(); - refreshSequencerAddr(); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - return true; - } - - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - if (!QFileInfo::exists(stg)) - return false; - - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err != WALLET_FFI_SUCCESS) { - qWarning() << "AmmUiBackend: openExisting failed, code" << err; + const QString config = configPath().isEmpty() ? defaultConfigPath() : configPath(); + const QString storage = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); + const WalletSession session = m_wallet->connect({ config, storage }); + if (!session.ok()) { + qWarning() << "AmmUiBackend: wallet open failed" + << walletFailureCode(session.failure); return false; } - persistConfigPath(cfg); - persistStoragePath(stg); + + persistConfigPath(config); + persistStoragePath(storage); + setWalletExists(true); setIsWalletOpen(true); QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); + applySnapshot(session.snapshot); return true; } void AmmUiBackend::disconnectWallet() { - // UI-local lock: persist wallet state, drop our view of it, and remember - // the choice. We do NOT close the core module's wallet handle — in Basecamp - // that instance is shared with other apps. - saveWallet(); + m_wallet->disconnect(); setIsWalletOpen(false); - m_accountModel->replaceFromJsonArray(QJsonArray()); + m_accountModel->replaceAccounts({}); QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true); } QString AmmUiBackend::createAccountPublic() { - const QString result = m_logos->logos_execution_zone.create_account_public(); - if (!result.isEmpty()) - refreshAccounts(); - return result; + const WalletAccountCreation creation = m_wallet->createAccount(true); + if (!creation.ok()) { + qWarning() << "AmmUiBackend: public account creation failed" + << walletFailureCode(creation.failure); + return {}; + } + if (creation.snapshot.ok()) + applySnapshot(creation.snapshot); + else + qWarning() << "AmmUiBackend: account refresh failed" + << walletFailureCode(creation.snapshot.failure); + return creation.accountId; } QString AmmUiBackend::createAccountPrivate() { - const QString result = m_logos->logos_execution_zone.create_account_private(); - if (!result.isEmpty()) - refreshAccounts(); - return result; + const WalletAccountCreation creation = m_wallet->createAccount(false); + if (!creation.ok()) { + qWarning() << "AmmUiBackend: private account creation failed" + << walletFailureCode(creation.failure); + return {}; + } + if (creation.snapshot.ok()) + applySnapshot(creation.snapshot); + else + qWarning() << "AmmUiBackend: account refresh failed" + << walletFailureCode(creation.snapshot.failure); + return creation.accountId; } void AmmUiBackend::refreshAccounts() { - const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - m_accountModel->replaceFromJsonArray(arr); - refreshBalances(); + const WalletSnapshot next = m_wallet->snapshot(true); + if (next.ok()) + applySnapshot(next); + else + qWarning() << "AmmUiBackend: wallet refresh failed" + << walletFailureCode(next.failure); } void AmmUiBackend::refreshBalances() { - refreshBlockHeights(); - if (currentBlockHeight() > 0) - m_logos->logos_execution_zone.sync_to_block(static_cast(currentBlockHeight())); - - for (int i = 0; i < m_accountModel->count(); ++i) { - const QModelIndex idx = m_accountModel->index(i, 0); - const QString addr = m_accountModel->data(idx, AccountModel::AddressRole).toString(); - const bool isPub = m_accountModel->data(idx, AccountModel::IsPublicRole).toBool(); - m_accountModel->setBalanceByAddress(addr, getBalance(addr, isPub)); - } - saveWallet(); + refreshAccounts(); } QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic) { - return m_logos->logos_execution_zone.get_balance(accountIdHex, isPublic); -} - -void AmmUiBackend::refreshBlockHeights() -{ - const int lastVal = m_logos->logos_execution_zone.get_last_synced_block(); - const int currentVal = m_logos->logos_execution_zone.get_current_block_height(); - if (lastSyncedBlock() != lastVal) - setLastSyncedBlock(lastVal); - if (currentBlockHeight() != currentVal) - setCurrentBlockHeight(currentVal); + const WalletSnapshot current = m_wallet->snapshot(); + for (const WalletAccount& account : current.accounts) { + if (account.address == accountIdHex && account.isPublic == isPublic) + return account.balance; + } + return {}; } -void AmmUiBackend::refreshSequencerAddr() +void AmmUiBackend::applySnapshot(const WalletSnapshot& snapshot) { - const QString addr = m_logos->logos_execution_zone.get_sequencer_addr(); - if (sequencerAddr() != addr) - setSequencerAddr(addr); - // Probe right away so the banner reflects the (possibly new) endpoint - // without waiting for the next periodic tick. + m_accountModel->replaceAccounts(snapshot.accounts); + setLastSyncedBlock(static_cast(snapshot.lastSyncedBlock)); + setCurrentBlockHeight(static_cast(snapshot.currentBlockHeight)); + setSequencerAddr(snapshot.sequencerAddress); checkReachability(); } void AmmUiBackend::checkReachability() { - const QString addr = sequencerAddr(); - if (addr.isEmpty()) + if (sequencerAddr().isEmpty()) return; - QNetworkRequest req{QUrl(addr)}; - req.setTransferTimeout(4000); - QNetworkReply* reply = m_net->get(req); + QNetworkRequest request{QUrl(sequencerAddr())}; + request.setTransferTimeout(4000); + QNetworkReply* reply = m_net->get(request); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - // Any HTTP response (even a 404) means the node is up; only a transport - // failure (connection refused, host not found, timeout) counts as down. - const bool gotHttpStatus = + const bool receivedHttp = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); - const bool reachable = gotHttpStatus || reply->error() == QNetworkReply::NoError; - if (sequencerReachable() != reachable) - setSequencerReachable(reachable); + setSequencerReachable(receivedHttp || reply->error() == QNetworkReply::NoError); reply->deleteLater(); }); } -void AmmUiBackend::saveWallet() -{ - if (isWalletOpen()) - m_logos->logos_execution_zone.save(); -} - -// These only update the in-session PROPs (so subsequent open/refresh calls -// reuse the same path). They are no longer written to QSettings: the app -// always resolves against the canonical wallet home, so there's nothing to -// remember across launches. void AmmUiBackend::persistConfigPath(const QString& path) { setConfigPath(toLocalPath(path)); @@ -351,51 +246,3 @@ void AmmUiBackend::persistStoragePath(const QString& path) { setStoragePath(toLocalPath(path)); } - -bool AmmUiBackend::changeSequencerAddr(QString url) -{ - const QString trimmed = url.trimmed(); - if (trimmed.isEmpty()) { - qWarning() << "AmmUiBackend: refusing to set empty sequencer_addr"; - return false; - } - - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - - // Preserve the other config fields (poll timeouts, retries) — only swap the - // endpoint. The wallet reads this file on open via from_path_or_initialize_default. - QJsonObject obj; - QFile in(cfg); - if (in.open(QIODevice::ReadOnly)) { - obj = QJsonDocument::fromJson(in.readAll()).object(); - in.close(); - } - obj.insert(QStringLiteral("sequencer_addr"), trimmed); - - QFile out(cfg); - if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - qWarning() << "AmmUiBackend: cannot write wallet config" << cfg; - return false; - } - out.write(QJsonDocument(obj).toJson(QJsonDocument::Indented)); - out.close(); - - // Re-open so the live wallet uses the new endpoint right away. - if (isWalletOpen()) { - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err != WALLET_FFI_SUCCESS) { - qWarning() << "AmmUiBackend: reopen after sequencer change failed, code" << err; - return false; - } - refreshSequencerAddr(); - refreshAccounts(); - } - return true; -} - -void AmmUiBackend::copyToClipboard(QString text) -{ - if (QGuiApplication::clipboard()) - QGuiApplication::clipboard()->setText(text); -} diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 14d1e80e..48f370ab 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -1,52 +1,41 @@ #ifndef AMM_UI_BACKEND_H #define AMM_UI_BACKEND_H -#include +#include + #include #include "rep_AmmUiBackend_source.h" -#include "AccountModel.h" +#include "WalletAccountModel.h" class LogosAPI; -struct LogosModules; +class LogosWalletProvider; class QNetworkAccessManager; class QTimer; -// Source-side implementation of the AmmUiBackend .rep interface. -// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and -// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. Talks to -// the core logos_execution_zone wallet module via LogosModules. class AmmUiBackend : public AmmUiBackendSimpleSource { Q_OBJECT - Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT) + Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT) public: explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); ~AmmUiBackend() override; - AccountModel* accountModel() const { return m_accountModel; } + WalletAccountModel* accountModel() const { return m_accountModel; } public slots: - // Overrides of the pure-virtual slots generated from the .rep. QString createAccountPublic() override; QString createAccountPrivate() override; void refreshAccounts() override; void refreshBalances() override; QString getBalance(QString accountIdHex, bool isPublic) override; - // Return the new wallet's BIP39 mnemonic (empty string on failure) so the - // UI can force a one-time seed-phrase backup step. QString createNewDefault(QString password) override; QString createNew(QString configPath, QString storagePath, QString password) override; bool openExisting() override; void disconnectWallet() override; - bool changeSequencerAddr(QString url) override; - void copyToClipboard(QString text) override; private: - // Per-app wallet home (kept distinct from the wallet's canonical - // ~/.lee/wallet so standalone instances stay isolated; Basecamp sharing - // is handled by adopting an already-open shared wallet on startup). static QString defaultWalletHome(); QString defaultConfigPath() const; QString defaultStoragePath() const; @@ -54,22 +43,12 @@ public slots: void persistConfigPath(const QString& path); void persistStoragePath(const QString& path); void openOrAdoptWallet(); - // True when the shared core already has a wallet open — including a freshly - // created one with zero accounts. See the definition for why list_accounts() - // alone is insufficient. - bool sharedWalletIsOpen(); - void refreshBlockHeights(); - void refreshSequencerAddr(); - void saveWallet(); - - // Probe the configured sequencer over HTTP and update sequencerReachable. + void applySnapshot(const WalletSnapshot& snapshot); void checkReachability(); - AccountModel* m_accountModel; - + WalletAccountModel* m_accountModel; LogosAPI* m_logosAPI; - LogosModules* m_logos; - + std::unique_ptr m_wallet; QNetworkAccessManager* m_net; QTimer* m_reachabilityTimer; }; diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index c9e2d50d..6023dbb6 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -37,10 +37,4 @@ class AmmUiBackend // Basecamp, does not close the wallet other apps share. SLOT(void disconnectWallet()) - // Settings. Rewrites the wallet config's sequencer_addr and re-opens the - // wallet so the new network takes effect immediately. - SLOT(bool changeSequencerAddr(QString url)) - - // Misc - SLOT(void copyToClipboard(QString text)) } From 049ccdf6a71de2e620ba9679095595153fde9769 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 15 Jul 2026 12:23:34 -0300 Subject: [PATCH 03/14] refactor(wallet): centralize UI wallet coordination Move wallet lifecycle, account-model synchronization, settings, and reachability behind a composition-based WalletController. Keep AmmUiBackend as the QtRO forwarding adapter while retaining direct WalletProvider ownership for program-specific reads and transaction submission. Adopt the thin-consumer direction demonstrated by PR #230 without coupling the shared module to generated QtRO base classes. Refs #227 Refs #230 --- apps/amm/src/AmmUiBackend.cpp | 236 +++-------------- apps/amm/src/AmmUiBackend.h | 19 +- apps/shared/wallet/CMakeLists.txt | 18 +- apps/shared/wallet/src/WalletController.cpp | 238 ++++++++++++++++++ apps/shared/wallet/src/WalletController.h | 67 +++++ .../tests/cpp/LogosWalletProviderTest.cpp | 85 +++++++ .../wallet/tests/support/FakeWalletProvider.h | 6 +- 7 files changed, 451 insertions(+), 218 deletions(-) create mode 100644 apps/shared/wallet/src/WalletController.cpp create mode 100644 apps/shared/wallet/src/WalletController.h diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index c2e75a6d..fbe6a92c 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,248 +1,86 @@ #include "AmmUiBackend.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - #include "LogosWalletProvider.h" +#include "WalletController.h" #include "logos_api.h" -namespace { -const char SETTINGS_ORG[] = "Logos"; -const char SETTINGS_APP[] = "AmmUI"; -const char DISCONNECTED_KEY[] = "disconnected"; -const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; - -QString toLocalPath(const QString& path) -{ - if (path.startsWith(QStringLiteral("file://")) || path.contains(QLatin1Char('/'))) - return QUrl::fromUserInput(path).toLocalFile(); - return path; -} -} - -QString AmmUiBackend::defaultWalletHome() -{ - const QByteArray override = qgetenv(WALLET_HOME_ENV); - if (!override.isEmpty()) - return QString::fromLocal8Bit(override); - return QDir::homePath() + QStringLiteral("/.lee/wallet"); -} - -QString AmmUiBackend::defaultConfigPath() const -{ - return defaultWalletHome() + QStringLiteral("/wallet_config.json"); -} - -QString AmmUiBackend::defaultStoragePath() const -{ - return defaultWalletHome() + QStringLiteral("/storage.json"); -} - AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), - m_accountModel(new WalletAccountModel(this)), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), m_wallet(std::make_unique(m_logosAPI)), - m_net(new QNetworkAccessManager(this)), - m_reachabilityTimer(new QTimer(this)) + m_walletController(std::make_unique( + *m_wallet, QStringLiteral("AmmUI"))) { - setIsWalletOpen(false); - setLastSyncedBlock(0); - setCurrentBlockHeight(0); - setWalletHome(defaultWalletHome()); - setSequencerReachable(true); - setWalletExists(QFileInfo::exists(defaultStoragePath())); - - m_reachabilityTimer->setInterval(10000); - connect(m_reachabilityTimer, &QTimer::timeout, - this, &AmmUiBackend::checkReachability); - m_reachabilityTimer->start(); - - QTimer::singleShot(0, this, &AmmUiBackend::openOrAdoptWallet); + connect(m_walletController.get(), &WalletController::stateChanged, + this, &AmmUiBackend::syncWalletState); + syncWalletState(); + m_walletController->start(); } AmmUiBackend::~AmmUiBackend() = default; -void AmmUiBackend::openOrAdoptWallet() -{ - if (QSettings(SETTINGS_ORG, SETTINGS_APP).value(DISCONNECTED_KEY, false).toBool()) - return; - - 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() << "AmmUiBackend: wallet connection failed" - << walletFailureCode(session.failure); - return; - } - - persistConfigPath(config); - persistStoragePath(storage); - setWalletExists(QFileInfo::exists(storage) || session.adopted); - setIsWalletOpen(true); - applySnapshot(session.snapshot); -} - -QString AmmUiBackend::createNewDefault(QString password) -{ - return createNew(defaultConfigPath(), defaultStoragePath(), password); -} - -QString AmmUiBackend::createNew(QString configPath, - QString storagePath, - QString password) -{ - const QString config = toLocalPath(configPath); - const QString storage = toLocalPath(storagePath); - const WalletCreation creation = m_wallet->createWallet( - { config, storage }, password); - if (creation.mnemonic.isEmpty()) { - qWarning() << "AmmUiBackend: wallet creation failed" - << walletFailureCode(creation.failure); - return {}; - } - - persistConfigPath(config); - persistStoragePath(storage); - setWalletExists(true); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - if (!creation.ok()) { - qWarning() << "AmmUiBackend: wallet creation failed" - << walletFailureCode(creation.failure); - return creation.mnemonic; - } - - setIsWalletOpen(true); - applySnapshot(creation.snapshot); - return creation.mnemonic; -} - -bool AmmUiBackend::openExisting() -{ - const QString config = configPath().isEmpty() ? defaultConfigPath() : configPath(); - const QString storage = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - const WalletSession session = m_wallet->connect({ config, storage }); - if (!session.ok()) { - qWarning() << "AmmUiBackend: wallet open failed" - << walletFailureCode(session.failure); - return false; - } - - persistConfigPath(config); - persistStoragePath(storage); - setWalletExists(true); - setIsWalletOpen(true); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - applySnapshot(session.snapshot); - return true; -} - -void AmmUiBackend::disconnectWallet() +WalletAccountModel* AmmUiBackend::accountModel() const { - m_wallet->disconnect(); - setIsWalletOpen(false); - m_accountModel->replaceAccounts({}); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true); + return m_walletController->accountModel(); } QString AmmUiBackend::createAccountPublic() { - const WalletAccountCreation creation = m_wallet->createAccount(true); - if (!creation.ok()) { - qWarning() << "AmmUiBackend: public account creation failed" - << walletFailureCode(creation.failure); - return {}; - } - if (creation.snapshot.ok()) - applySnapshot(creation.snapshot); - else - qWarning() << "AmmUiBackend: account refresh failed" - << walletFailureCode(creation.snapshot.failure); - return creation.accountId; + return m_walletController->createAccount(true); } QString AmmUiBackend::createAccountPrivate() { - const WalletAccountCreation creation = m_wallet->createAccount(false); - if (!creation.ok()) { - qWarning() << "AmmUiBackend: private account creation failed" - << walletFailureCode(creation.failure); - return {}; - } - if (creation.snapshot.ok()) - applySnapshot(creation.snapshot); - else - qWarning() << "AmmUiBackend: account refresh failed" - << walletFailureCode(creation.snapshot.failure); - return creation.accountId; + return m_walletController->createAccount(false); } void AmmUiBackend::refreshAccounts() { - const WalletSnapshot next = m_wallet->snapshot(true); - if (next.ok()) - applySnapshot(next); - else - qWarning() << "AmmUiBackend: wallet refresh failed" - << walletFailureCode(next.failure); + m_walletController->refresh(); } void AmmUiBackend::refreshBalances() { - refreshAccounts(); + m_walletController->refresh(); } QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic) { - const WalletSnapshot current = m_wallet->snapshot(); - for (const WalletAccount& account : current.accounts) { - if (account.address == accountIdHex && account.isPublic == isPublic) - return account.balance; - } - return {}; + return m_walletController->balance(accountIdHex, isPublic); } -void AmmUiBackend::applySnapshot(const WalletSnapshot& snapshot) +QString AmmUiBackend::createNewDefault(QString password) { - m_accountModel->replaceAccounts(snapshot.accounts); - setLastSyncedBlock(static_cast(snapshot.lastSyncedBlock)); - setCurrentBlockHeight(static_cast(snapshot.currentBlockHeight)); - setSequencerAddr(snapshot.sequencerAddress); - checkReachability(); + return m_walletController->createDefaultWallet(password); } -void AmmUiBackend::checkReachability() +QString AmmUiBackend::createNew(QString configPath, + QString storagePath, + QString password) { - if (sequencerAddr().isEmpty()) - return; + return m_walletController->createWallet(configPath, storagePath, password); +} - QNetworkRequest request{QUrl(sequencerAddr())}; - request.setTransferTimeout(4000); - QNetworkReply* reply = m_net->get(request); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const bool receivedHttp = - reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); - setSequencerReachable(receivedHttp || reply->error() == QNetworkReply::NoError); - reply->deleteLater(); - }); +bool AmmUiBackend::openExisting() +{ + return m_walletController->open(); } -void AmmUiBackend::persistConfigPath(const QString& path) +void AmmUiBackend::disconnectWallet() { - setConfigPath(toLocalPath(path)); + m_walletController->disconnect(); } -void AmmUiBackend::persistStoragePath(const QString& path) +void AmmUiBackend::syncWalletState() { - setStoragePath(toLocalPath(path)); + const WalletUiState& state = m_walletController->state(); + setIsWalletOpen(state.isWalletOpen); + setWalletExists(state.walletExists); + setConfigPath(state.configPath); + setStoragePath(state.storagePath); + setWalletHome(state.walletHome); + setLastSyncedBlock(state.lastSyncedBlock); + setCurrentBlockHeight(state.currentBlockHeight); + setSequencerAddr(state.sequencerAddress); + setSequencerReachable(state.sequencerReachable); } diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 48f370ab..99fa7e03 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -11,8 +11,7 @@ class LogosAPI; class LogosWalletProvider; -class QNetworkAccessManager; -class QTimer; +class WalletController; class AmmUiBackend : public AmmUiBackendSimpleSource { Q_OBJECT @@ -22,7 +21,7 @@ class AmmUiBackend : public AmmUiBackendSimpleSource { explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); ~AmmUiBackend() override; - WalletAccountModel* accountModel() const { return m_accountModel; } + WalletAccountModel* accountModel() const; public slots: QString createAccountPublic() override; @@ -36,21 +35,11 @@ public slots: void disconnectWallet() override; private: - static QString defaultWalletHome(); - QString defaultConfigPath() const; - QString defaultStoragePath() const; + void syncWalletState(); - void persistConfigPath(const QString& path); - void persistStoragePath(const QString& path); - void openOrAdoptWallet(); - void applySnapshot(const WalletSnapshot& snapshot); - void checkReachability(); - - WalletAccountModel* m_accountModel; LogosAPI* m_logosAPI; std::unique_ptr m_wallet; - QNetworkAccessManager* m_net; - QTimer* m_reachabilityTimer; + std::unique_ptr m_walletController; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 5c5403ff..37f16630 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -18,6 +18,9 @@ if(LOGOS_WALLET_BUILD_ACCESS endif() find_package(Qt6 6.8 REQUIRED COMPONENTS Core) +if(LOGOS_WALLET_BUILD_ACCESS OR BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Network) +endif() set(CMAKE_AUTOMOC ON) if(LOGOS_WALLET_BUILD_ACCESS) @@ -28,6 +31,8 @@ if(LOGOS_WALLET_BUILD_ACCESS) src/LogosWalletProvider.cpp src/WalletAccountModel.h src/WalletAccountModel.cpp + src/WalletController.h + src/WalletController.cpp ) set_target_properties(logos_wallet_access PROPERTIES AUTOMOC ON @@ -41,7 +46,10 @@ if(LOGOS_WALLET_BUILD_ACCESS) "${LOGOS_WALLET_GENERATED_DIR}" "${LOGOS_WALLET_GENERATED_DIR}/include" ) - target_link_libraries(logos_wallet_access PUBLIC Qt6::Core) + target_link_libraries(logos_wallet_access + PUBLIC Qt6::Core + PRIVATE Qt6::Network + ) endif() if(LOGOS_WALLET_BUILD_QML) @@ -127,6 +135,8 @@ if(BUILD_TESTING) src/LogosWalletProvider.cpp src/WalletAccountModel.cpp src/WalletAccountModel.h + src/WalletController.cpp + src/WalletController.h ) set_target_properties(logos_wallet_access_test PROPERTIES AUTOMOC ON) target_compile_features(logos_wallet_access_test PRIVATE cxx_std_17) @@ -135,7 +145,11 @@ if(BUILD_TESTING) tests/support src ) - target_link_libraries(logos_wallet_access_test PRIVATE Qt6::Core Qt6::Test) + target_link_libraries(logos_wallet_access_test PRIVATE + Qt6::Core + Qt6::Network + Qt6::Test + ) add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test) if(LOGOS_WALLET_BUILD_QML) diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp new file mode 100644 index 00000000..5cffdfd5 --- /dev/null +++ b/apps/shared/wallet/src/WalletController.cpp @@ -0,0 +1,238 @@ +#include "WalletController.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "WalletAccountModel.h" + +namespace { +const char SETTINGS_ORG[] = "Logos"; +const char DISCONNECTED_KEY[] = "disconnected"; +const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; + +QString toLocalPath(const QString& path) +{ + if (path.startsWith(QStringLiteral("file://")) || path.contains(QLatin1Char('/'))) + return QUrl::fromUserInput(path).toLocalFile(); + return path; +} +} + +WalletController::WalletController(WalletProvider& wallet, + QString settingsApplication, + QObject* parent) + : QObject(parent), + m_wallet(wallet), + m_settingsApplication(std::move(settingsApplication)), + m_accountModel(new WalletAccountModel(this)), + m_network(new QNetworkAccessManager(this)), + m_reachabilityTimer(new QTimer(this)) +{ + m_state.walletHome = defaultWalletHome(); + m_state.walletExists = QFileInfo::exists(defaultStoragePath()); + + m_reachabilityTimer->setInterval(10000); + connect(m_reachabilityTimer, &QTimer::timeout, + this, &WalletController::checkReachability); +} + +WalletController::~WalletController() = default; + +QString WalletController::defaultWalletHome() +{ + const QByteArray override = qgetenv(WALLET_HOME_ENV); + if (!override.isEmpty()) + return QString::fromLocal8Bit(override); + return QDir::homePath() + QStringLiteral("/.lee/wallet"); +} + +QString WalletController::defaultConfigPath() const +{ + return m_state.walletHome + QStringLiteral("/wallet_config.json"); +} + +QString WalletController::defaultStoragePath() const +{ + return m_state.walletHome + QStringLiteral("/storage.json"); +} + +void WalletController::start() +{ + if (m_started) + return; + m_started = true; + m_reachabilityTimer->start(); + QTimer::singleShot(0, this, &WalletController::openOnStartup); +} + +void WalletController::openOnStartup() +{ + if (QSettings(SETTINGS_ORG, m_settingsApplication) + .value(DISCONNECTED_KEY, false).toBool()) { + return; + } + + 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; + } + + m_state.configPath = config; + m_state.storagePath = storage; + m_state.walletExists = QFileInfo::exists(storage) || session.adopted; + m_state.isWalletOpen = true; + applySnapshot(session.snapshot); +} + +QString WalletController::createDefaultWallet(const QString& password) +{ + return createWallet(defaultConfigPath(), defaultStoragePath(), password); +} + +QString WalletController::createWallet(const QString& configPath, + const QString& storagePath, + const QString& password) +{ + const QString config = toLocalPath(configPath); + const QString storage = toLocalPath(storagePath); + const WalletCreation creation = m_wallet.createWallet( + { config, storage }, password); + if (creation.mnemonic.isEmpty()) { + qWarning() << "WalletController: wallet creation failed" + << walletFailureCode(creation.failure); + return {}; + } + + 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); + emit stateChanged(); + return creation.mnemonic; + } + + m_state.isWalletOpen = true; + applySnapshot(creation.snapshot); + return creation.mnemonic; +} + +bool WalletController::open() +{ + const QString config = m_state.configPath.isEmpty() + ? 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; +} + +void WalletController::disconnect() +{ + m_wallet.disconnect(); + m_state.isWalletOpen = false; + m_accountModel->replaceAccounts({}); + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); + emit stateChanged(); +} + +QString WalletController::createAccount(bool isPublic) +{ + const WalletAccountCreation creation = m_wallet.createAccount(isPublic); + if (!creation.ok()) { + qWarning() << "WalletController: account creation failed" + << walletFailureCode(creation.failure); + return {}; + } + if (creation.snapshot.ok()) { + applySnapshot(creation.snapshot); + } else { + qWarning() << "WalletController: account refresh failed" + << walletFailureCode(creation.snapshot.failure); + } + 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); + } +} + +QString WalletController::balance(const QString& accountId, bool isPublic) +{ + const WalletSnapshot current = m_wallet.snapshot(); + for (const WalletAccount& account : current.accounts) { + if (account.address == accountId && account.isPublic == isPublic) + return account.balance; + } + return {}; +} + +void WalletController::applySnapshot(const WalletSnapshot& snapshot) +{ + m_accountModel->replaceAccounts(snapshot.accounts); + m_state.lastSyncedBlock = static_cast(snapshot.lastSyncedBlock); + m_state.currentBlockHeight = static_cast(snapshot.currentBlockHeight); + m_state.sequencerAddress = snapshot.sequencerAddress; + emit stateChanged(); + checkReachability(); +} + +void WalletController::checkReachability() +{ + if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty()) + return; + + QNetworkRequest request{QUrl(m_state.sequencerAddress)}; + request.setTransferTimeout(4000); + QNetworkReply* reply = m_network->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + if (!m_state.isWalletOpen) { + reply->deleteLater(); + return; + } + const bool receivedHttp = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); + const bool reachable = receivedHttp || reply->error() == QNetworkReply::NoError; + if (m_state.sequencerReachable != reachable) { + m_state.sequencerReachable = reachable; + emit stateChanged(); + } + reply->deleteLater(); + }); +} diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h new file mode 100644 index 00000000..c27f9cef --- /dev/null +++ b/apps/shared/wallet/src/WalletController.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include + +#include "WalletProvider.h" + +class QNetworkAccessManager; +class QTimer; +class WalletAccountModel; + +struct WalletUiState { + bool isWalletOpen = false; + bool walletExists = false; + QString configPath; + QString storagePath; + QString walletHome; + int lastSyncedBlock = 0; + int currentBlockHeight = 0; + QString sequencerAddress; + bool sequencerReachable = true; +}; + +class WalletController final : public QObject { + Q_OBJECT + +public: + // The provider must outlive the controller. + explicit WalletController(WalletProvider& wallet, + QString settingsApplication, + QObject* parent = nullptr); + ~WalletController() override; + + WalletAccountModel* accountModel() const { return m_accountModel; } + const WalletUiState& state() const { return m_state; } + + void start(); + QString createAccount(bool isPublic); + void refresh(); + QString balance(const QString& accountId, bool isPublic); + QString createDefaultWallet(const QString& password); + QString createWallet(const QString& configPath, + const QString& storagePath, + const QString& password); + bool open(); + void disconnect(); + +signals: + void stateChanged(); + +private: + static QString defaultWalletHome(); + QString defaultConfigPath() const; + QString defaultStoragePath() const; + + void openOnStartup(); + void applySnapshot(const WalletSnapshot& snapshot); + void checkReachability(); + + WalletProvider& m_wallet; + QString m_settingsApplication; + WalletUiState m_state; + WalletAccountModel* m_accountModel; + QNetworkAccessManager* m_network; + QTimer* m_reachabilityTimer; + bool m_started = false; +}; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index 33672d90..9701d6b5 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -1,13 +1,17 @@ #include #include #include +#include +#include #include #include +#include #include #include "FakeWalletProvider.h" #include "LogosWalletProvider.h" #include "WalletAccountModel.h" +#include "WalletController.h" #include "logos_sdk.h" namespace { @@ -53,6 +57,8 @@ private slots: void rejectsInvalidSubmissionResponses(); void exposesStableAccountModelRoles(); void fakeProviderImplementsConsumerContract(); + void controllerOwnsUiWalletFlow(); + void controllerStopsReachabilityChecksAfterDisconnect(); }; void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() @@ -355,6 +361,8 @@ void LogosWalletProviderTest::fakeProviderImplementsConsumerContract() QCOMPARE(provider.snapshot(true).accounts.size(), 1); QVERIFY(provider.lastForceRefresh); + QCOMPARE(provider.readPublicAccount(ACCOUNT_B).accountId, ACCOUNT_B); + QCOMPARE(provider.readCalls, 1); WalletTransaction transaction { PROGRAM_ID, { ACCOUNT_A }, { true }, { 9 } }; QVERIFY(provider.submitPublicTransaction(transaction).accepted()); QCOMPARE(provider.lastTransaction.instruction, transaction.instruction); @@ -362,6 +370,83 @@ void LogosWalletProviderTest::fakeProviderImplementsConsumerContract() QCOMPARE(provider.disconnectCalls, 1); } +void LogosWalletProviderTest::controllerOwnsUiWalletFlow() +{ + const QString settingsApplication = QStringLiteral("WalletControllerTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.connectResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true }, + }; + provider.connectResult.snapshot.lastSyncedBlock = 7; + provider.connectResult.snapshot.currentBlockHeight = 8; + + WalletController controller(provider, settingsApplication); + QSignalSpy stateChanged(&controller, &WalletController::stateChanged); + + QVERIFY(controller.open()); + QCOMPARE(provider.connectCalls, 1); + QVERIFY(controller.state().isWalletOpen); + QVERIFY(controller.state().walletExists); + QCOMPARE(controller.state().lastSyncedBlock, 7); + QCOMPARE(controller.state().currentBlockHeight, 8); + QCOMPARE(controller.accountModel()->count(), 1); + + provider.snapshotResult.accounts = { + { ACCOUNT_A, QStringLiteral("9"), true }, + }; + controller.refresh(); + QVERIFY(provider.lastForceRefresh); + QCOMPARE(controller.balance(ACCOUNT_A, true), QStringLiteral("9")); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("9"), true }, + { ACCOUNT_B, QStringLiteral("3"), false }, + }; + QCOMPARE(controller.createAccount(false), ACCOUNT_B); + QVERIFY(!provider.lastAccountWasPublic); + QCOMPARE(controller.accountModel()->count(), 2); + + controller.disconnect(); + QCOMPARE(provider.disconnectCalls, 1); + QVERIFY(!controller.state().isWalletOpen); + QCOMPARE(controller.accountModel()->count(), 0); + QVERIFY(stateChanged.count() >= 4); + + settings.clear(); +} + +void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() +{ + const QString settingsApplication = QStringLiteral("WalletReachabilityTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.connectResult.snapshot.sequencerAddress = QStringLiteral("http://127.0.0.1:1"); + WalletController controller(provider, settingsApplication); + auto* network = controller.findChild(); + QVERIFY(network); + QSignalSpy finished(network, &QNetworkAccessManager::finished); + + QVERIFY(controller.open()); + QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000); + controller.disconnect(); + finished.clear(); + + auto* timer = controller.findChild(); + QVERIFY(timer); + timer->setInterval(1); + controller.start(); + QTest::qWait(50); + QCOMPARE(finished.count(), 0); + + settings.clear(); +} + QTEST_GUILESS_MAIN(LogosWalletProviderTest) #include "LogosWalletProviderTest.moc" diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h index 03b12ada..d5dea0ad 100644 --- a/apps/shared/wallet/tests/support/FakeWalletProvider.h +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -55,10 +55,12 @@ class FakeWalletProvider final : public WalletProvider { return createAccountResult; } - WalletAccountRead readPublicAccount(const QString&) const override + WalletAccountRead readPublicAccount(const QString& accountId) const override { ++readCalls; - return readResult; + WalletAccountRead result = readResult; + result.accountId = accountId; + return result; } WalletSubmission submitPublicTransaction( From d47cc70ce7d38e0677e21ba74025a7f128bfa475 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:42:07 +0200 Subject: [PATCH 04/14] fix(apps/amm): use patched LEZ version that nukes xcrun cache Done in https://github.com/logos-blockchain/logos-execution-zone/pull/629/changes Needed to avoid `missing metal toolchain` issue on macOS --- apps/amm/flake.lock | 896 ++++++++++++++++++++++---------------------- apps/amm/flake.nix | 13 +- 2 files changed, 460 insertions(+), 449 deletions(-) diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index e0014b39..8f79e67e 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,6 +1,6 @@ { "nodes": { - "crane_2": { + "crane": { "locked": { "lastModified": 1780532242, "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", @@ -76,7 +76,7 @@ }, "logos-blockchain-circuits": { "inputs": { - "nixpkgs": "nixpkgs_125" + "nixpkgs": "nixpkgs_124" }, "locked": { "lastModified": 1781004244, @@ -2225,7 +2225,7 @@ }, "logos-execution-zone": { "inputs": { - "crane": "crane_2", + "crane": "crane", "logos-blockchain-circuits": "logos-blockchain-circuits", "logos-liblogos": "logos-liblogos_4", "nixpkgs": [ @@ -2238,17 +2238,17 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1782428741, - "narHash": "sha256-ltLcysXUdVUXAe25Tl8x7e7ZsTzj1sHlyS3glp97TAo=", + "lastModified": 1784202021, + "narHash": "sha256-BG94Ob5tbJyw7GclNBdnOWqcBLRld8sc+xeOU8LI5RQ=", "owner": "logos-blockchain", "repo": "logos-execution-zone", - "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", + "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", "type": "github" }, "original": { "owner": "logos-blockchain", "repo": "logos-execution-zone", - "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", + "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", "type": "github" } }, @@ -4763,7 +4763,7 @@ }, "logos-nix": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs" }, "locked": { "lastModified": 1774455309, @@ -4781,7 +4781,7 @@ }, "logos-nix_10": { "inputs": { - "nixpkgs": "nixpkgs_11" + "nixpkgs": "nixpkgs_10" }, "locked": { "lastModified": 1774455309, @@ -4799,7 +4799,7 @@ }, "logos-nix_100": { "inputs": { - "nixpkgs": "nixpkgs_101" + "nixpkgs": "nixpkgs_100" }, "locked": { "lastModified": 1773955630, @@ -4817,7 +4817,7 @@ }, "logos-nix_101": { "inputs": { - "nixpkgs": "nixpkgs_102" + "nixpkgs": "nixpkgs_101" }, "locked": { "lastModified": 1773955630, @@ -4835,7 +4835,7 @@ }, "logos-nix_102": { "inputs": { - "nixpkgs": "nixpkgs_103" + "nixpkgs": "nixpkgs_102" }, "locked": { "lastModified": 1773955630, @@ -4853,7 +4853,7 @@ }, "logos-nix_103": { "inputs": { - "nixpkgs": "nixpkgs_104" + "nixpkgs": "nixpkgs_103" }, "locked": { "lastModified": 1773955630, @@ -4871,7 +4871,7 @@ }, "logos-nix_104": { "inputs": { - "nixpkgs": "nixpkgs_105" + "nixpkgs": "nixpkgs_104" }, "locked": { "lastModified": 1773955630, @@ -4889,7 +4889,7 @@ }, "logos-nix_105": { "inputs": { - "nixpkgs": "nixpkgs_106" + "nixpkgs": "nixpkgs_105" }, "locked": { "lastModified": 1774455309, @@ -4907,7 +4907,7 @@ }, "logos-nix_106": { "inputs": { - "nixpkgs": "nixpkgs_107" + "nixpkgs": "nixpkgs_106" }, "locked": { "lastModified": 1773955630, @@ -4925,7 +4925,7 @@ }, "logos-nix_107": { "inputs": { - "nixpkgs": "nixpkgs_108" + "nixpkgs": "nixpkgs_107" }, "locked": { "lastModified": 1774455309, @@ -4943,7 +4943,7 @@ }, "logos-nix_108": { "inputs": { - "nixpkgs": "nixpkgs_109" + "nixpkgs": "nixpkgs_108" }, "locked": { "lastModified": 1774455309, @@ -4961,7 +4961,7 @@ }, "logos-nix_109": { "inputs": { - "nixpkgs": "nixpkgs_110" + "nixpkgs": "nixpkgs_109" }, "locked": { "lastModified": 1773955630, @@ -4979,7 +4979,7 @@ }, "logos-nix_11": { "inputs": { - "nixpkgs": "nixpkgs_12" + "nixpkgs": "nixpkgs_11" }, "locked": { "lastModified": 1773955630, @@ -4997,7 +4997,7 @@ }, "logos-nix_110": { "inputs": { - "nixpkgs": "nixpkgs_111" + "nixpkgs": "nixpkgs_110" }, "locked": { "lastModified": 1773955630, @@ -5015,7 +5015,7 @@ }, "logos-nix_111": { "inputs": { - "nixpkgs": "nixpkgs_112" + "nixpkgs": "nixpkgs_111" }, "locked": { "lastModified": 1774455309, @@ -5033,7 +5033,7 @@ }, "logos-nix_112": { "inputs": { - "nixpkgs": "nixpkgs_113" + "nixpkgs": "nixpkgs_112" }, "locked": { "lastModified": 1774455309, @@ -5051,7 +5051,7 @@ }, "logos-nix_113": { "inputs": { - "nixpkgs": "nixpkgs_114" + "nixpkgs": "nixpkgs_113" }, "locked": { "lastModified": 1773955630, @@ -5069,7 +5069,7 @@ }, "logos-nix_114": { "inputs": { - "nixpkgs": "nixpkgs_115" + "nixpkgs": "nixpkgs_114" }, "locked": { "lastModified": 1773955630, @@ -5087,7 +5087,7 @@ }, "logos-nix_115": { "inputs": { - "nixpkgs": "nixpkgs_116" + "nixpkgs": "nixpkgs_115" }, "locked": { "lastModified": 1774455309, @@ -5105,7 +5105,7 @@ }, "logos-nix_116": { "inputs": { - "nixpkgs": "nixpkgs_117" + "nixpkgs": "nixpkgs_116" }, "locked": { "lastModified": 1774455309, @@ -5123,7 +5123,7 @@ }, "logos-nix_117": { "inputs": { - "nixpkgs": "nixpkgs_118" + "nixpkgs": "nixpkgs_117" }, "locked": { "lastModified": 1773955630, @@ -5141,7 +5141,7 @@ }, "logos-nix_118": { "inputs": { - "nixpkgs": "nixpkgs_119" + "nixpkgs": "nixpkgs_118" }, "locked": { "lastModified": 1773955630, @@ -5159,7 +5159,7 @@ }, "logos-nix_119": { "inputs": { - "nixpkgs": "nixpkgs_120" + "nixpkgs": "nixpkgs_119" }, "locked": { "lastModified": 1773955630, @@ -5177,7 +5177,7 @@ }, "logos-nix_12": { "inputs": { - "nixpkgs": "nixpkgs_13" + "nixpkgs": "nixpkgs_12" }, "locked": { "lastModified": 1774455309, @@ -5195,7 +5195,7 @@ }, "logos-nix_120": { "inputs": { - "nixpkgs": "nixpkgs_121" + "nixpkgs": "nixpkgs_120" }, "locked": { "lastModified": 1773955630, @@ -5213,7 +5213,7 @@ }, "logos-nix_121": { "inputs": { - "nixpkgs": "nixpkgs_122" + "nixpkgs": "nixpkgs_121" }, "locked": { "lastModified": 1774455309, @@ -5231,7 +5231,7 @@ }, "logos-nix_122": { "inputs": { - "nixpkgs": "nixpkgs_123" + "nixpkgs": "nixpkgs_122" }, "locked": { "lastModified": 1773955630, @@ -5249,7 +5249,7 @@ }, "logos-nix_123": { "inputs": { - "nixpkgs": "nixpkgs_124" + "nixpkgs": "nixpkgs_123" }, "locked": { "lastModified": 1773955630, @@ -5267,7 +5267,7 @@ }, "logos-nix_124": { "inputs": { - "nixpkgs": "nixpkgs_126" + "nixpkgs": "nixpkgs_125" }, "locked": { "lastModified": 1774455309, @@ -5285,7 +5285,7 @@ }, "logos-nix_125": { "inputs": { - "nixpkgs": "nixpkgs_127" + "nixpkgs": "nixpkgs_126" }, "locked": { "lastModified": 1774455309, @@ -5303,7 +5303,7 @@ }, "logos-nix_126": { "inputs": { - "nixpkgs": "nixpkgs_128" + "nixpkgs": "nixpkgs_127" }, "locked": { "lastModified": 1774455309, @@ -5321,7 +5321,7 @@ }, "logos-nix_127": { "inputs": { - "nixpkgs": "nixpkgs_129" + "nixpkgs": "nixpkgs_128" }, "locked": { "lastModified": 1774455309, @@ -5339,7 +5339,7 @@ }, "logos-nix_128": { "inputs": { - "nixpkgs": "nixpkgs_130" + "nixpkgs": "nixpkgs_129" }, "locked": { "lastModified": 1773955630, @@ -5357,7 +5357,7 @@ }, "logos-nix_129": { "inputs": { - "nixpkgs": "nixpkgs_131" + "nixpkgs": "nixpkgs_130" }, "locked": { "lastModified": 1774455309, @@ -5375,7 +5375,7 @@ }, "logos-nix_13": { "inputs": { - "nixpkgs": "nixpkgs_14" + "nixpkgs": "nixpkgs_13" }, "locked": { "lastModified": 1773955630, @@ -5393,7 +5393,7 @@ }, "logos-nix_130": { "inputs": { - "nixpkgs": "nixpkgs_132" + "nixpkgs": "nixpkgs_131" }, "locked": { "lastModified": 1774455309, @@ -5411,7 +5411,7 @@ }, "logos-nix_131": { "inputs": { - "nixpkgs": "nixpkgs_133" + "nixpkgs": "nixpkgs_132" }, "locked": { "lastModified": 1774455309, @@ -5429,7 +5429,7 @@ }, "logos-nix_132": { "inputs": { - "nixpkgs": "nixpkgs_134" + "nixpkgs": "nixpkgs_133" }, "locked": { "lastModified": 1774455309, @@ -5447,7 +5447,7 @@ }, "logos-nix_133": { "inputs": { - "nixpkgs": "nixpkgs_135" + "nixpkgs": "nixpkgs_134" }, "locked": { "lastModified": 1774455309, @@ -5465,7 +5465,7 @@ }, "logos-nix_134": { "inputs": { - "nixpkgs": "nixpkgs_136" + "nixpkgs": "nixpkgs_135" }, "locked": { "lastModified": 1774455309, @@ -5483,7 +5483,7 @@ }, "logos-nix_135": { "inputs": { - "nixpkgs": "nixpkgs_137" + "nixpkgs": "nixpkgs_136" }, "locked": { "lastModified": 1773955630, @@ -5501,7 +5501,7 @@ }, "logos-nix_136": { "inputs": { - "nixpkgs": "nixpkgs_138" + "nixpkgs": "nixpkgs_137" }, "locked": { "lastModified": 1774455309, @@ -5519,7 +5519,7 @@ }, "logos-nix_137": { "inputs": { - "nixpkgs": "nixpkgs_139" + "nixpkgs": "nixpkgs_138" }, "locked": { "lastModified": 1773955630, @@ -5537,7 +5537,7 @@ }, "logos-nix_138": { "inputs": { - "nixpkgs": "nixpkgs_140" + "nixpkgs": "nixpkgs_139" }, "locked": { "lastModified": 1774455309, @@ -5555,7 +5555,7 @@ }, "logos-nix_139": { "inputs": { - "nixpkgs": "nixpkgs_141" + "nixpkgs": "nixpkgs_140" }, "locked": { "lastModified": 1773955630, @@ -5573,7 +5573,7 @@ }, "logos-nix_14": { "inputs": { - "nixpkgs": "nixpkgs_15" + "nixpkgs": "nixpkgs_14" }, "locked": { "lastModified": 1774455309, @@ -5591,7 +5591,7 @@ }, "logos-nix_140": { "inputs": { - "nixpkgs": "nixpkgs_142" + "nixpkgs": "nixpkgs_141" }, "locked": { "lastModified": 1774455309, @@ -5609,7 +5609,7 @@ }, "logos-nix_141": { "inputs": { - "nixpkgs": "nixpkgs_143" + "nixpkgs": "nixpkgs_142" }, "locked": { "lastModified": 1774455309, @@ -5627,7 +5627,7 @@ }, "logos-nix_142": { "inputs": { - "nixpkgs": "nixpkgs_144" + "nixpkgs": "nixpkgs_143" }, "locked": { "lastModified": 1773955630, @@ -5645,7 +5645,7 @@ }, "logos-nix_143": { "inputs": { - "nixpkgs": "nixpkgs_145" + "nixpkgs": "nixpkgs_144" }, "locked": { "lastModified": 1774455309, @@ -5663,7 +5663,7 @@ }, "logos-nix_144": { "inputs": { - "nixpkgs": "nixpkgs_146" + "nixpkgs": "nixpkgs_145" }, "locked": { "lastModified": 1773955630, @@ -5681,7 +5681,7 @@ }, "logos-nix_145": { "inputs": { - "nixpkgs": "nixpkgs_147" + "nixpkgs": "nixpkgs_146" }, "locked": { "lastModified": 1774455309, @@ -5699,7 +5699,7 @@ }, "logos-nix_146": { "inputs": { - "nixpkgs": "nixpkgs_148" + "nixpkgs": "nixpkgs_147" }, "locked": { "lastModified": 1773955630, @@ -5717,7 +5717,7 @@ }, "logos-nix_147": { "inputs": { - "nixpkgs": "nixpkgs_149" + "nixpkgs": "nixpkgs_148" }, "locked": { "lastModified": 1774455309, @@ -5735,7 +5735,7 @@ }, "logos-nix_148": { "inputs": { - "nixpkgs": "nixpkgs_150" + "nixpkgs": "nixpkgs_149" }, "locked": { "lastModified": 1773955630, @@ -5753,7 +5753,7 @@ }, "logos-nix_149": { "inputs": { - "nixpkgs": "nixpkgs_151" + "nixpkgs": "nixpkgs_150" }, "locked": { "lastModified": 1773955630, @@ -5771,7 +5771,7 @@ }, "logos-nix_15": { "inputs": { - "nixpkgs": "nixpkgs_16" + "nixpkgs": "nixpkgs_15" }, "locked": { "lastModified": 1773955630, @@ -5789,7 +5789,7 @@ }, "logos-nix_150": { "inputs": { - "nixpkgs": "nixpkgs_152" + "nixpkgs": "nixpkgs_151" }, "locked": { "lastModified": 1774455309, @@ -5807,7 +5807,7 @@ }, "logos-nix_151": { "inputs": { - "nixpkgs": "nixpkgs_153" + "nixpkgs": "nixpkgs_152" }, "locked": { "lastModified": 1773955630, @@ -5825,7 +5825,7 @@ }, "logos-nix_152": { "inputs": { - "nixpkgs": "nixpkgs_154" + "nixpkgs": "nixpkgs_153" }, "locked": { "lastModified": 1773955630, @@ -5843,7 +5843,7 @@ }, "logos-nix_153": { "inputs": { - "nixpkgs": "nixpkgs_155" + "nixpkgs": "nixpkgs_154" }, "locked": { "lastModified": 1773955630, @@ -5861,7 +5861,7 @@ }, "logos-nix_154": { "inputs": { - "nixpkgs": "nixpkgs_156" + "nixpkgs": "nixpkgs_155" }, "locked": { "lastModified": 1773955630, @@ -5879,7 +5879,7 @@ }, "logos-nix_155": { "inputs": { - "nixpkgs": "nixpkgs_157" + "nixpkgs": "nixpkgs_156" }, "locked": { "lastModified": 1774455309, @@ -5897,7 +5897,7 @@ }, "logos-nix_156": { "inputs": { - "nixpkgs": "nixpkgs_158" + "nixpkgs": "nixpkgs_157" }, "locked": { "lastModified": 1773955630, @@ -5915,7 +5915,7 @@ }, "logos-nix_157": { "inputs": { - "nixpkgs": "nixpkgs_159" + "nixpkgs": "nixpkgs_158" }, "locked": { "lastModified": 1774455309, @@ -5933,7 +5933,7 @@ }, "logos-nix_158": { "inputs": { - "nixpkgs": "nixpkgs_160" + "nixpkgs": "nixpkgs_159" }, "locked": { "lastModified": 1774455309, @@ -5951,7 +5951,7 @@ }, "logos-nix_159": { "inputs": { - "nixpkgs": "nixpkgs_161" + "nixpkgs": "nixpkgs_160" }, "locked": { "lastModified": 1773955630, @@ -5969,7 +5969,7 @@ }, "logos-nix_16": { "inputs": { - "nixpkgs": "nixpkgs_17" + "nixpkgs": "nixpkgs_16" }, "locked": { "lastModified": 1774455309, @@ -5987,7 +5987,7 @@ }, "logos-nix_160": { "inputs": { - "nixpkgs": "nixpkgs_162" + "nixpkgs": "nixpkgs_161" }, "locked": { "lastModified": 1773955630, @@ -6005,7 +6005,7 @@ }, "logos-nix_161": { "inputs": { - "nixpkgs": "nixpkgs_163" + "nixpkgs": "nixpkgs_162" }, "locked": { "lastModified": 1773955630, @@ -6023,7 +6023,7 @@ }, "logos-nix_162": { "inputs": { - "nixpkgs": "nixpkgs_164" + "nixpkgs": "nixpkgs_163" }, "locked": { "lastModified": 1773955630, @@ -6041,7 +6041,7 @@ }, "logos-nix_163": { "inputs": { - "nixpkgs": "nixpkgs_165" + "nixpkgs": "nixpkgs_164" }, "locked": { "lastModified": 1773955630, @@ -6059,7 +6059,7 @@ }, "logos-nix_164": { "inputs": { - "nixpkgs": "nixpkgs_166" + "nixpkgs": "nixpkgs_165" }, "locked": { "lastModified": 1774455309, @@ -6077,7 +6077,7 @@ }, "logos-nix_165": { "inputs": { - "nixpkgs": "nixpkgs_167" + "nixpkgs": "nixpkgs_166" }, "locked": { "lastModified": 1773955630, @@ -6095,7 +6095,7 @@ }, "logos-nix_166": { "inputs": { - "nixpkgs": "nixpkgs_168" + "nixpkgs": "nixpkgs_167" }, "locked": { "lastModified": 1774455309, @@ -6113,7 +6113,7 @@ }, "logos-nix_167": { "inputs": { - "nixpkgs": "nixpkgs_169" + "nixpkgs": "nixpkgs_168" }, "locked": { "lastModified": 1774455309, @@ -6131,7 +6131,7 @@ }, "logos-nix_168": { "inputs": { - "nixpkgs": "nixpkgs_170" + "nixpkgs": "nixpkgs_169" }, "locked": { "lastModified": 1773955630, @@ -6149,7 +6149,7 @@ }, "logos-nix_169": { "inputs": { - "nixpkgs": "nixpkgs_171" + "nixpkgs": "nixpkgs_170" }, "locked": { "lastModified": 1773955630, @@ -6167,7 +6167,7 @@ }, "logos-nix_17": { "inputs": { - "nixpkgs": "nixpkgs_18" + "nixpkgs": "nixpkgs_17" }, "locked": { "lastModified": 1773955630, @@ -6185,7 +6185,7 @@ }, "logos-nix_170": { "inputs": { - "nixpkgs": "nixpkgs_172" + "nixpkgs": "nixpkgs_171" }, "locked": { "lastModified": 1774455309, @@ -6203,7 +6203,7 @@ }, "logos-nix_171": { "inputs": { - "nixpkgs": "nixpkgs_173" + "nixpkgs": "nixpkgs_172" }, "locked": { "lastModified": 1774455309, @@ -6221,7 +6221,7 @@ }, "logos-nix_172": { "inputs": { - "nixpkgs": "nixpkgs_174" + "nixpkgs": "nixpkgs_173" }, "locked": { "lastModified": 1773955630, @@ -6239,7 +6239,7 @@ }, "logos-nix_173": { "inputs": { - "nixpkgs": "nixpkgs_175" + "nixpkgs": "nixpkgs_174" }, "locked": { "lastModified": 1773955630, @@ -6257,7 +6257,7 @@ }, "logos-nix_174": { "inputs": { - "nixpkgs": "nixpkgs_176" + "nixpkgs": "nixpkgs_175" }, "locked": { "lastModified": 1774455309, @@ -6275,7 +6275,7 @@ }, "logos-nix_175": { "inputs": { - "nixpkgs": "nixpkgs_177" + "nixpkgs": "nixpkgs_176" }, "locked": { "lastModified": 1774455309, @@ -6293,7 +6293,7 @@ }, "logos-nix_176": { "inputs": { - "nixpkgs": "nixpkgs_178" + "nixpkgs": "nixpkgs_177" }, "locked": { "lastModified": 1773955630, @@ -6311,7 +6311,7 @@ }, "logos-nix_177": { "inputs": { - "nixpkgs": "nixpkgs_179" + "nixpkgs": "nixpkgs_178" }, "locked": { "lastModified": 1773955630, @@ -6329,7 +6329,7 @@ }, "logos-nix_178": { "inputs": { - "nixpkgs": "nixpkgs_180" + "nixpkgs": "nixpkgs_179" }, "locked": { "lastModified": 1773955630, @@ -6347,7 +6347,7 @@ }, "logos-nix_179": { "inputs": { - "nixpkgs": "nixpkgs_181" + "nixpkgs": "nixpkgs_180" }, "locked": { "lastModified": 1773955630, @@ -6365,7 +6365,7 @@ }, "logos-nix_18": { "inputs": { - "nixpkgs": "nixpkgs_19" + "nixpkgs": "nixpkgs_18" }, "locked": { "lastModified": 1773955630, @@ -6383,7 +6383,7 @@ }, "logos-nix_180": { "inputs": { - "nixpkgs": "nixpkgs_182" + "nixpkgs": "nixpkgs_181" }, "locked": { "lastModified": 1774455309, @@ -6401,7 +6401,7 @@ }, "logos-nix_181": { "inputs": { - "nixpkgs": "nixpkgs_183" + "nixpkgs": "nixpkgs_182" }, "locked": { "lastModified": 1773955630, @@ -6419,7 +6419,7 @@ }, "logos-nix_182": { "inputs": { - "nixpkgs": "nixpkgs_184" + "nixpkgs": "nixpkgs_183" }, "locked": { "lastModified": 1773955630, @@ -6437,7 +6437,7 @@ }, "logos-nix_183": { "inputs": { - "nixpkgs": "nixpkgs_185" + "nixpkgs": "nixpkgs_184" }, "locked": { "lastModified": 1774455309, @@ -6455,7 +6455,7 @@ }, "logos-nix_184": { "inputs": { - "nixpkgs": "nixpkgs_186" + "nixpkgs": "nixpkgs_185" }, "locked": { "lastModified": 1773955630, @@ -6473,7 +6473,7 @@ }, "logos-nix_185": { "inputs": { - "nixpkgs": "nixpkgs_187" + "nixpkgs": "nixpkgs_186" }, "locked": { "lastModified": 1774455309, @@ -6491,7 +6491,7 @@ }, "logos-nix_186": { "inputs": { - "nixpkgs": "nixpkgs_188" + "nixpkgs": "nixpkgs_187" }, "locked": { "lastModified": 1773955630, @@ -6509,7 +6509,7 @@ }, "logos-nix_187": { "inputs": { - "nixpkgs": "nixpkgs_189" + "nixpkgs": "nixpkgs_188" }, "locked": { "lastModified": 1774455309, @@ -6527,7 +6527,7 @@ }, "logos-nix_188": { "inputs": { - "nixpkgs": "nixpkgs_190" + "nixpkgs": "nixpkgs_189" }, "locked": { "lastModified": 1773955630, @@ -6545,7 +6545,7 @@ }, "logos-nix_189": { "inputs": { - "nixpkgs": "nixpkgs_191" + "nixpkgs": "nixpkgs_190" }, "locked": { "lastModified": 1774455309, @@ -6563,7 +6563,7 @@ }, "logos-nix_19": { "inputs": { - "nixpkgs": "nixpkgs_20" + "nixpkgs": "nixpkgs_19" }, "locked": { "lastModified": 1774455309, @@ -6581,7 +6581,7 @@ }, "logos-nix_190": { "inputs": { - "nixpkgs": "nixpkgs_192" + "nixpkgs": "nixpkgs_191" }, "locked": { "lastModified": 1773955630, @@ -6599,7 +6599,7 @@ }, "logos-nix_191": { "inputs": { - "nixpkgs": "nixpkgs_193" + "nixpkgs": "nixpkgs_192" }, "locked": { "lastModified": 1774455309, @@ -6617,7 +6617,7 @@ }, "logos-nix_192": { "inputs": { - "nixpkgs": "nixpkgs_194" + "nixpkgs": "nixpkgs_193" }, "locked": { "lastModified": 1773955630, @@ -6635,7 +6635,7 @@ }, "logos-nix_193": { "inputs": { - "nixpkgs": "nixpkgs_195" + "nixpkgs": "nixpkgs_194" }, "locked": { "lastModified": 1773955630, @@ -6653,7 +6653,7 @@ }, "logos-nix_194": { "inputs": { - "nixpkgs": "nixpkgs_196" + "nixpkgs": "nixpkgs_195" }, "locked": { "lastModified": 1774455309, @@ -6671,7 +6671,7 @@ }, "logos-nix_195": { "inputs": { - "nixpkgs": "nixpkgs_197" + "nixpkgs": "nixpkgs_196" }, "locked": { "lastModified": 1773955630, @@ -6689,7 +6689,7 @@ }, "logos-nix_196": { "inputs": { - "nixpkgs": "nixpkgs_198" + "nixpkgs": "nixpkgs_197" }, "locked": { "lastModified": 1773955630, @@ -6707,7 +6707,7 @@ }, "logos-nix_197": { "inputs": { - "nixpkgs": "nixpkgs_199" + "nixpkgs": "nixpkgs_198" }, "locked": { "lastModified": 1773955630, @@ -6725,7 +6725,7 @@ }, "logos-nix_198": { "inputs": { - "nixpkgs": "nixpkgs_200" + "nixpkgs": "nixpkgs_199" }, "locked": { "lastModified": 1773955630, @@ -6743,7 +6743,7 @@ }, "logos-nix_199": { "inputs": { - "nixpkgs": "nixpkgs_201" + "nixpkgs": "nixpkgs_200" }, "locked": { "lastModified": 1774455309, @@ -6761,7 +6761,7 @@ }, "logos-nix_2": { "inputs": { - "nixpkgs": "nixpkgs_3" + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1773955630, @@ -6779,7 +6779,7 @@ }, "logos-nix_20": { "inputs": { - "nixpkgs": "nixpkgs_21" + "nixpkgs": "nixpkgs_20" }, "locked": { "lastModified": 1773955630, @@ -6797,7 +6797,7 @@ }, "logos-nix_200": { "inputs": { - "nixpkgs": "nixpkgs_202" + "nixpkgs": "nixpkgs_201" }, "locked": { "lastModified": 1773955630, @@ -6815,7 +6815,7 @@ }, "logos-nix_201": { "inputs": { - "nixpkgs": "nixpkgs_203" + "nixpkgs": "nixpkgs_202" }, "locked": { "lastModified": 1774455309, @@ -6833,7 +6833,7 @@ }, "logos-nix_202": { "inputs": { - "nixpkgs": "nixpkgs_204" + "nixpkgs": "nixpkgs_203" }, "locked": { "lastModified": 1774455309, @@ -6851,7 +6851,7 @@ }, "logos-nix_203": { "inputs": { - "nixpkgs": "nixpkgs_205" + "nixpkgs": "nixpkgs_204" }, "locked": { "lastModified": 1773955630, @@ -6869,7 +6869,7 @@ }, "logos-nix_204": { "inputs": { - "nixpkgs": "nixpkgs_206" + "nixpkgs": "nixpkgs_205" }, "locked": { "lastModified": 1773955630, @@ -6887,7 +6887,7 @@ }, "logos-nix_205": { "inputs": { - "nixpkgs": "nixpkgs_207" + "nixpkgs": "nixpkgs_206" }, "locked": { "lastModified": 1773955630, @@ -6905,7 +6905,7 @@ }, "logos-nix_206": { "inputs": { - "nixpkgs": "nixpkgs_208" + "nixpkgs": "nixpkgs_207" }, "locked": { "lastModified": 1773955630, @@ -6923,7 +6923,7 @@ }, "logos-nix_207": { "inputs": { - "nixpkgs": "nixpkgs_209" + "nixpkgs": "nixpkgs_208" }, "locked": { "lastModified": 1773955630, @@ -6941,7 +6941,7 @@ }, "logos-nix_208": { "inputs": { - "nixpkgs": "nixpkgs_210" + "nixpkgs": "nixpkgs_209" }, "locked": { "lastModified": 1774455309, @@ -6959,7 +6959,7 @@ }, "logos-nix_209": { "inputs": { - "nixpkgs": "nixpkgs_211" + "nixpkgs": "nixpkgs_210" }, "locked": { "lastModified": 1773955630, @@ -6977,7 +6977,7 @@ }, "logos-nix_21": { "inputs": { - "nixpkgs": "nixpkgs_22" + "nixpkgs": "nixpkgs_21" }, "locked": { "lastModified": 1773955630, @@ -6995,7 +6995,7 @@ }, "logos-nix_210": { "inputs": { - "nixpkgs": "nixpkgs_212" + "nixpkgs": "nixpkgs_211" }, "locked": { "lastModified": 1774455309, @@ -7013,7 +7013,7 @@ }, "logos-nix_211": { "inputs": { - "nixpkgs": "nixpkgs_213" + "nixpkgs": "nixpkgs_212" }, "locked": { "lastModified": 1774455309, @@ -7031,7 +7031,7 @@ }, "logos-nix_212": { "inputs": { - "nixpkgs": "nixpkgs_214" + "nixpkgs": "nixpkgs_213" }, "locked": { "lastModified": 1773955630, @@ -7049,7 +7049,7 @@ }, "logos-nix_213": { "inputs": { - "nixpkgs": "nixpkgs_215" + "nixpkgs": "nixpkgs_214" }, "locked": { "lastModified": 1773955630, @@ -7067,7 +7067,7 @@ }, "logos-nix_214": { "inputs": { - "nixpkgs": "nixpkgs_216" + "nixpkgs": "nixpkgs_215" }, "locked": { "lastModified": 1774455309, @@ -7085,7 +7085,7 @@ }, "logos-nix_215": { "inputs": { - "nixpkgs": "nixpkgs_217" + "nixpkgs": "nixpkgs_216" }, "locked": { "lastModified": 1774455309, @@ -7103,7 +7103,7 @@ }, "logos-nix_216": { "inputs": { - "nixpkgs": "nixpkgs_218" + "nixpkgs": "nixpkgs_217" }, "locked": { "lastModified": 1773955630, @@ -7121,7 +7121,7 @@ }, "logos-nix_217": { "inputs": { - "nixpkgs": "nixpkgs_219" + "nixpkgs": "nixpkgs_218" }, "locked": { "lastModified": 1773955630, @@ -7139,7 +7139,7 @@ }, "logos-nix_218": { "inputs": { - "nixpkgs": "nixpkgs_220" + "nixpkgs": "nixpkgs_219" }, "locked": { "lastModified": 1774455309, @@ -7157,7 +7157,7 @@ }, "logos-nix_219": { "inputs": { - "nixpkgs": "nixpkgs_221" + "nixpkgs": "nixpkgs_220" }, "locked": { "lastModified": 1774455309, @@ -7175,7 +7175,7 @@ }, "logos-nix_22": { "inputs": { - "nixpkgs": "nixpkgs_23" + "nixpkgs": "nixpkgs_22" }, "locked": { "lastModified": 1773955630, @@ -7193,7 +7193,7 @@ }, "logos-nix_220": { "inputs": { - "nixpkgs": "nixpkgs_222" + "nixpkgs": "nixpkgs_221" }, "locked": { "lastModified": 1773955630, @@ -7211,7 +7211,7 @@ }, "logos-nix_221": { "inputs": { - "nixpkgs": "nixpkgs_223" + "nixpkgs": "nixpkgs_222" }, "locked": { "lastModified": 1773955630, @@ -7229,7 +7229,7 @@ }, "logos-nix_222": { "inputs": { - "nixpkgs": "nixpkgs_224" + "nixpkgs": "nixpkgs_223" }, "locked": { "lastModified": 1773955630, @@ -7247,7 +7247,7 @@ }, "logos-nix_223": { "inputs": { - "nixpkgs": "nixpkgs_225" + "nixpkgs": "nixpkgs_224" }, "locked": { "lastModified": 1773955630, @@ -7265,7 +7265,7 @@ }, "logos-nix_224": { "inputs": { - "nixpkgs": "nixpkgs_226" + "nixpkgs": "nixpkgs_225" }, "locked": { "lastModified": 1774455309, @@ -7283,7 +7283,7 @@ }, "logos-nix_225": { "inputs": { - "nixpkgs": "nixpkgs_227" + "nixpkgs": "nixpkgs_226" }, "locked": { "lastModified": 1773955630, @@ -7301,7 +7301,7 @@ }, "logos-nix_226": { "inputs": { - "nixpkgs": "nixpkgs_228" + "nixpkgs": "nixpkgs_227" }, "locked": { "lastModified": 1773955630, @@ -7319,7 +7319,7 @@ }, "logos-nix_227": { "inputs": { - "nixpkgs": "nixpkgs_229" + "nixpkgs": "nixpkgs_228" }, "locked": { "lastModified": 1774455309, @@ -7337,7 +7337,7 @@ }, "logos-nix_228": { "inputs": { - "nixpkgs": "nixpkgs_230" + "nixpkgs": "nixpkgs_229" }, "locked": { "lastModified": 1773955630, @@ -7355,7 +7355,7 @@ }, "logos-nix_229": { "inputs": { - "nixpkgs": "nixpkgs_231" + "nixpkgs": "nixpkgs_230" }, "locked": { "lastModified": 1774455309, @@ -7373,7 +7373,7 @@ }, "logos-nix_23": { "inputs": { - "nixpkgs": "nixpkgs_24" + "nixpkgs": "nixpkgs_23" }, "locked": { "lastModified": 1773955630, @@ -7391,7 +7391,7 @@ }, "logos-nix_230": { "inputs": { - "nixpkgs": "nixpkgs_232" + "nixpkgs": "nixpkgs_231" }, "locked": { "lastModified": 1774455309, @@ -7409,7 +7409,7 @@ }, "logos-nix_231": { "inputs": { - "nixpkgs": "nixpkgs_233" + "nixpkgs": "nixpkgs_232" }, "locked": { "lastModified": 1773955630, @@ -7427,7 +7427,7 @@ }, "logos-nix_232": { "inputs": { - "nixpkgs": "nixpkgs_234" + "nixpkgs": "nixpkgs_233" }, "locked": { "lastModified": 1773955630, @@ -7445,7 +7445,7 @@ }, "logos-nix_233": { "inputs": { - "nixpkgs": "nixpkgs_235" + "nixpkgs": "nixpkgs_234" }, "locked": { "lastModified": 1773955630, @@ -7463,7 +7463,7 @@ }, "logos-nix_234": { "inputs": { - "nixpkgs": "nixpkgs_236" + "nixpkgs": "nixpkgs_235" }, "locked": { "lastModified": 1773955630, @@ -7481,7 +7481,7 @@ }, "logos-nix_235": { "inputs": { - "nixpkgs": "nixpkgs_237" + "nixpkgs": "nixpkgs_236" }, "locked": { "lastModified": 1773955630, @@ -7499,7 +7499,7 @@ }, "logos-nix_236": { "inputs": { - "nixpkgs": "nixpkgs_238" + "nixpkgs": "nixpkgs_237" }, "locked": { "lastModified": 1774455309, @@ -7517,7 +7517,7 @@ }, "logos-nix_237": { "inputs": { - "nixpkgs": "nixpkgs_239" + "nixpkgs": "nixpkgs_238" }, "locked": { "lastModified": 1773955630, @@ -7535,7 +7535,7 @@ }, "logos-nix_238": { "inputs": { - "nixpkgs": "nixpkgs_240" + "nixpkgs": "nixpkgs_239" }, "locked": { "lastModified": 1774455309, @@ -7553,7 +7553,7 @@ }, "logos-nix_239": { "inputs": { - "nixpkgs": "nixpkgs_241" + "nixpkgs": "nixpkgs_240" }, "locked": { "lastModified": 1774455309, @@ -7571,7 +7571,7 @@ }, "logos-nix_24": { "inputs": { - "nixpkgs": "nixpkgs_25" + "nixpkgs": "nixpkgs_24" }, "locked": { "lastModified": 1774455309, @@ -7589,7 +7589,7 @@ }, "logos-nix_240": { "inputs": { - "nixpkgs": "nixpkgs_242" + "nixpkgs": "nixpkgs_241" }, "locked": { "lastModified": 1773955630, @@ -7607,7 +7607,7 @@ }, "logos-nix_241": { "inputs": { - "nixpkgs": "nixpkgs_243" + "nixpkgs": "nixpkgs_242" }, "locked": { "lastModified": 1773955630, @@ -7625,7 +7625,7 @@ }, "logos-nix_242": { "inputs": { - "nixpkgs": "nixpkgs_244" + "nixpkgs": "nixpkgs_243" }, "locked": { "lastModified": 1774455309, @@ -7643,7 +7643,7 @@ }, "logos-nix_243": { "inputs": { - "nixpkgs": "nixpkgs_245" + "nixpkgs": "nixpkgs_244" }, "locked": { "lastModified": 1774455309, @@ -7661,7 +7661,7 @@ }, "logos-nix_244": { "inputs": { - "nixpkgs": "nixpkgs_246" + "nixpkgs": "nixpkgs_245" }, "locked": { "lastModified": 1773955630, @@ -7679,7 +7679,7 @@ }, "logos-nix_245": { "inputs": { - "nixpkgs": "nixpkgs_247" + "nixpkgs": "nixpkgs_246" }, "locked": { "lastModified": 1773955630, @@ -7697,7 +7697,7 @@ }, "logos-nix_246": { "inputs": { - "nixpkgs": "nixpkgs_248" + "nixpkgs": "nixpkgs_247" }, "locked": { "lastModified": 1774455309, @@ -7715,7 +7715,7 @@ }, "logos-nix_247": { "inputs": { - "nixpkgs": "nixpkgs_249" + "nixpkgs": "nixpkgs_248" }, "locked": { "lastModified": 1774455309, @@ -7733,7 +7733,7 @@ }, "logos-nix_248": { "inputs": { - "nixpkgs": "nixpkgs_250" + "nixpkgs": "nixpkgs_249" }, "locked": { "lastModified": 1773955630, @@ -7751,7 +7751,7 @@ }, "logos-nix_249": { "inputs": { - "nixpkgs": "nixpkgs_251" + "nixpkgs": "nixpkgs_250" }, "locked": { "lastModified": 1773955630, @@ -7769,7 +7769,7 @@ }, "logos-nix_25": { "inputs": { - "nixpkgs": "nixpkgs_26" + "nixpkgs": "nixpkgs_25" }, "locked": { "lastModified": 1773955630, @@ -7787,7 +7787,7 @@ }, "logos-nix_250": { "inputs": { - "nixpkgs": "nixpkgs_252" + "nixpkgs": "nixpkgs_251" }, "locked": { "lastModified": 1773955630, @@ -7805,7 +7805,7 @@ }, "logos-nix_251": { "inputs": { - "nixpkgs": "nixpkgs_253" + "nixpkgs": "nixpkgs_252" }, "locked": { "lastModified": 1773955630, @@ -7823,7 +7823,7 @@ }, "logos-nix_252": { "inputs": { - "nixpkgs": "nixpkgs_254" + "nixpkgs": "nixpkgs_253" }, "locked": { "lastModified": 1774455309, @@ -7841,7 +7841,7 @@ }, "logos-nix_253": { "inputs": { - "nixpkgs": "nixpkgs_255" + "nixpkgs": "nixpkgs_254" }, "locked": { "lastModified": 1773955630, @@ -7859,7 +7859,7 @@ }, "logos-nix_254": { "inputs": { - "nixpkgs": "nixpkgs_256" + "nixpkgs": "nixpkgs_255" }, "locked": { "lastModified": 1773955630, @@ -7877,7 +7877,7 @@ }, "logos-nix_255": { "inputs": { - "nixpkgs": "nixpkgs_257" + "nixpkgs": "nixpkgs_256" }, "locked": { "lastModified": 1774455309, @@ -7895,7 +7895,7 @@ }, "logos-nix_256": { "inputs": { - "nixpkgs": "nixpkgs_258" + "nixpkgs": "nixpkgs_257" }, "locked": { "lastModified": 1774455309, @@ -7913,7 +7913,7 @@ }, "logos-nix_257": { "inputs": { - "nixpkgs": "nixpkgs_259" + "nixpkgs": "nixpkgs_258" }, "locked": { "lastModified": 1773955630, @@ -7931,7 +7931,7 @@ }, "logos-nix_258": { "inputs": { - "nixpkgs": "nixpkgs_260" + "nixpkgs": "nixpkgs_259" }, "locked": { "lastModified": 1774455309, @@ -7949,7 +7949,7 @@ }, "logos-nix_259": { "inputs": { - "nixpkgs": "nixpkgs_261" + "nixpkgs": "nixpkgs_260" }, "locked": { "lastModified": 1774455309, @@ -7967,7 +7967,7 @@ }, "logos-nix_26": { "inputs": { - "nixpkgs": "nixpkgs_27" + "nixpkgs": "nixpkgs_26" }, "locked": { "lastModified": 1774455309, @@ -7985,7 +7985,7 @@ }, "logos-nix_260": { "inputs": { - "nixpkgs": "nixpkgs_262" + "nixpkgs": "nixpkgs_261" }, "locked": { "lastModified": 1774455309, @@ -8003,7 +8003,7 @@ }, "logos-nix_261": { "inputs": { - "nixpkgs": "nixpkgs_263" + "nixpkgs": "nixpkgs_262" }, "locked": { "lastModified": 1774455309, @@ -8021,7 +8021,7 @@ }, "logos-nix_262": { "inputs": { - "nixpkgs": "nixpkgs_264" + "nixpkgs": "nixpkgs_263" }, "locked": { "lastModified": 1773955630, @@ -8039,7 +8039,7 @@ }, "logos-nix_263": { "inputs": { - "nixpkgs": "nixpkgs_265" + "nixpkgs": "nixpkgs_264" }, "locked": { "lastModified": 1773955630, @@ -8057,7 +8057,7 @@ }, "logos-nix_264": { "inputs": { - "nixpkgs": "nixpkgs_266" + "nixpkgs": "nixpkgs_265" }, "locked": { "lastModified": 1773955630, @@ -8075,7 +8075,7 @@ }, "logos-nix_265": { "inputs": { - "nixpkgs": "nixpkgs_267" + "nixpkgs": "nixpkgs_266" }, "locked": { "lastModified": 1773955630, @@ -8093,7 +8093,7 @@ }, "logos-nix_266": { "inputs": { - "nixpkgs": "nixpkgs_268" + "nixpkgs": "nixpkgs_267" }, "locked": { "lastModified": 1774455309, @@ -8111,7 +8111,7 @@ }, "logos-nix_267": { "inputs": { - "nixpkgs": "nixpkgs_269" + "nixpkgs": "nixpkgs_268" }, "locked": { "lastModified": 1774455309, @@ -8129,7 +8129,7 @@ }, "logos-nix_268": { "inputs": { - "nixpkgs": "nixpkgs_270" + "nixpkgs": "nixpkgs_269" }, "locked": { "lastModified": 1773955630, @@ -8147,7 +8147,7 @@ }, "logos-nix_269": { "inputs": { - "nixpkgs": "nixpkgs_272" + "nixpkgs": "nixpkgs_271" }, "locked": { "lastModified": 1774455309, @@ -8165,7 +8165,7 @@ }, "logos-nix_27": { "inputs": { - "nixpkgs": "nixpkgs_28" + "nixpkgs": "nixpkgs_27" }, "locked": { "lastModified": 1774455309, @@ -8183,7 +8183,7 @@ }, "logos-nix_270": { "inputs": { - "nixpkgs": "nixpkgs_273" + "nixpkgs": "nixpkgs_272" }, "locked": { "lastModified": 1773955630, @@ -8201,7 +8201,7 @@ }, "logos-nix_271": { "inputs": { - "nixpkgs": "nixpkgs_274" + "nixpkgs": "nixpkgs_273" }, "locked": { "lastModified": 1774455309, @@ -8219,7 +8219,7 @@ }, "logos-nix_272": { "inputs": { - "nixpkgs": "nixpkgs_275" + "nixpkgs": "nixpkgs_274" }, "locked": { "lastModified": 1773955630, @@ -8237,7 +8237,7 @@ }, "logos-nix_273": { "inputs": { - "nixpkgs": "nixpkgs_276" + "nixpkgs": "nixpkgs_275" }, "locked": { "lastModified": 1774455309, @@ -8255,7 +8255,7 @@ }, "logos-nix_274": { "inputs": { - "nixpkgs": "nixpkgs_277" + "nixpkgs": "nixpkgs_276" }, "locked": { "lastModified": 1773955630, @@ -8273,7 +8273,7 @@ }, "logos-nix_275": { "inputs": { - "nixpkgs": "nixpkgs_278" + "nixpkgs": "nixpkgs_277" }, "locked": { "lastModified": 1774455309, @@ -8291,7 +8291,7 @@ }, "logos-nix_276": { "inputs": { - "nixpkgs": "nixpkgs_279" + "nixpkgs": "nixpkgs_278" }, "locked": { "lastModified": 1774455309, @@ -8309,7 +8309,7 @@ }, "logos-nix_277": { "inputs": { - "nixpkgs": "nixpkgs_280" + "nixpkgs": "nixpkgs_279" }, "locked": { "lastModified": 1774455309, @@ -8327,7 +8327,7 @@ }, "logos-nix_278": { "inputs": { - "nixpkgs": "nixpkgs_281" + "nixpkgs": "nixpkgs_280" }, "locked": { "lastModified": 1774455309, @@ -8345,7 +8345,7 @@ }, "logos-nix_279": { "inputs": { - "nixpkgs": "nixpkgs_282" + "nixpkgs": "nixpkgs_281" }, "locked": { "lastModified": 1773955630, @@ -8363,7 +8363,7 @@ }, "logos-nix_28": { "inputs": { - "nixpkgs": "nixpkgs_29" + "nixpkgs": "nixpkgs_28" }, "locked": { "lastModified": 1773955630, @@ -8381,7 +8381,7 @@ }, "logos-nix_280": { "inputs": { - "nixpkgs": "nixpkgs_283" + "nixpkgs": "nixpkgs_282" }, "locked": { "lastModified": 1774455309, @@ -8399,7 +8399,7 @@ }, "logos-nix_281": { "inputs": { - "nixpkgs": "nixpkgs_284" + "nixpkgs": "nixpkgs_283" }, "locked": { "lastModified": 1773955630, @@ -8417,7 +8417,7 @@ }, "logos-nix_282": { "inputs": { - "nixpkgs": "nixpkgs_285" + "nixpkgs": "nixpkgs_284" }, "locked": { "lastModified": 1774455309, @@ -8435,7 +8435,7 @@ }, "logos-nix_283": { "inputs": { - "nixpkgs": "nixpkgs_286" + "nixpkgs": "nixpkgs_285" }, "locked": { "lastModified": 1773955630, @@ -8453,7 +8453,7 @@ }, "logos-nix_284": { "inputs": { - "nixpkgs": "nixpkgs_287" + "nixpkgs": "nixpkgs_286" }, "locked": { "lastModified": 1774455309, @@ -8471,7 +8471,7 @@ }, "logos-nix_285": { "inputs": { - "nixpkgs": "nixpkgs_288" + "nixpkgs": "nixpkgs_287" }, "locked": { "lastModified": 1773955630, @@ -8489,7 +8489,7 @@ }, "logos-nix_286": { "inputs": { - "nixpkgs": "nixpkgs_289" + "nixpkgs": "nixpkgs_288" }, "locked": { "lastModified": 1773955630, @@ -8507,7 +8507,7 @@ }, "logos-nix_287": { "inputs": { - "nixpkgs": "nixpkgs_290" + "nixpkgs": "nixpkgs_289" }, "locked": { "lastModified": 1774455309, @@ -8525,7 +8525,7 @@ }, "logos-nix_288": { "inputs": { - "nixpkgs": "nixpkgs_291" + "nixpkgs": "nixpkgs_290" }, "locked": { "lastModified": 1773955630, @@ -8543,7 +8543,7 @@ }, "logos-nix_289": { "inputs": { - "nixpkgs": "nixpkgs_292" + "nixpkgs": "nixpkgs_291" }, "locked": { "lastModified": 1773955630, @@ -8561,7 +8561,7 @@ }, "logos-nix_29": { "inputs": { - "nixpkgs": "nixpkgs_30" + "nixpkgs": "nixpkgs_29" }, "locked": { "lastModified": 1773955630, @@ -8579,7 +8579,7 @@ }, "logos-nix_290": { "inputs": { - "nixpkgs": "nixpkgs_293" + "nixpkgs": "nixpkgs_292" }, "locked": { "lastModified": 1773955630, @@ -8597,7 +8597,7 @@ }, "logos-nix_291": { "inputs": { - "nixpkgs": "nixpkgs_294" + "nixpkgs": "nixpkgs_293" }, "locked": { "lastModified": 1773955630, @@ -8615,7 +8615,7 @@ }, "logos-nix_292": { "inputs": { - "nixpkgs": "nixpkgs_295" + "nixpkgs": "nixpkgs_294" }, "locked": { "lastModified": 1774455309, @@ -8633,7 +8633,7 @@ }, "logos-nix_293": { "inputs": { - "nixpkgs": "nixpkgs_296" + "nixpkgs": "nixpkgs_295" }, "locked": { "lastModified": 1773955630, @@ -8651,7 +8651,7 @@ }, "logos-nix_294": { "inputs": { - "nixpkgs": "nixpkgs_297" + "nixpkgs": "nixpkgs_296" }, "locked": { "lastModified": 1774455309, @@ -8669,7 +8669,7 @@ }, "logos-nix_295": { "inputs": { - "nixpkgs": "nixpkgs_298" + "nixpkgs": "nixpkgs_297" }, "locked": { "lastModified": 1774455309, @@ -8687,7 +8687,7 @@ }, "logos-nix_296": { "inputs": { - "nixpkgs": "nixpkgs_299" + "nixpkgs": "nixpkgs_298" }, "locked": { "lastModified": 1773955630, @@ -8705,7 +8705,7 @@ }, "logos-nix_297": { "inputs": { - "nixpkgs": "nixpkgs_300" + "nixpkgs": "nixpkgs_299" }, "locked": { "lastModified": 1773955630, @@ -8723,7 +8723,7 @@ }, "logos-nix_298": { "inputs": { - "nixpkgs": "nixpkgs_301" + "nixpkgs": "nixpkgs_300" }, "locked": { "lastModified": 1773955630, @@ -8741,7 +8741,7 @@ }, "logos-nix_299": { "inputs": { - "nixpkgs": "nixpkgs_302" + "nixpkgs": "nixpkgs_301" }, "locked": { "lastModified": 1773955630, @@ -8759,7 +8759,7 @@ }, "logos-nix_3": { "inputs": { - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_3" }, "locked": { "lastModified": 1774455309, @@ -8777,7 +8777,7 @@ }, "logos-nix_30": { "inputs": { - "nixpkgs": "nixpkgs_31" + "nixpkgs": "nixpkgs_30" }, "locked": { "lastModified": 1773955630, @@ -8795,7 +8795,7 @@ }, "logos-nix_300": { "inputs": { - "nixpkgs": "nixpkgs_303" + "nixpkgs": "nixpkgs_302" }, "locked": { "lastModified": 1773955630, @@ -8813,7 +8813,7 @@ }, "logos-nix_301": { "inputs": { - "nixpkgs": "nixpkgs_304" + "nixpkgs": "nixpkgs_303" }, "locked": { "lastModified": 1774455309, @@ -8831,7 +8831,7 @@ }, "logos-nix_302": { "inputs": { - "nixpkgs": "nixpkgs_305" + "nixpkgs": "nixpkgs_304" }, "locked": { "lastModified": 1773955630, @@ -8849,7 +8849,7 @@ }, "logos-nix_303": { "inputs": { - "nixpkgs": "nixpkgs_306" + "nixpkgs": "nixpkgs_305" }, "locked": { "lastModified": 1774455309, @@ -8867,7 +8867,7 @@ }, "logos-nix_304": { "inputs": { - "nixpkgs": "nixpkgs_307" + "nixpkgs": "nixpkgs_306" }, "locked": { "lastModified": 1774455309, @@ -8885,7 +8885,7 @@ }, "logos-nix_305": { "inputs": { - "nixpkgs": "nixpkgs_308" + "nixpkgs": "nixpkgs_307" }, "locked": { "lastModified": 1773955630, @@ -8903,7 +8903,7 @@ }, "logos-nix_306": { "inputs": { - "nixpkgs": "nixpkgs_309" + "nixpkgs": "nixpkgs_308" }, "locked": { "lastModified": 1773955630, @@ -8921,7 +8921,7 @@ }, "logos-nix_307": { "inputs": { - "nixpkgs": "nixpkgs_310" + "nixpkgs": "nixpkgs_309" }, "locked": { "lastModified": 1774455309, @@ -8939,7 +8939,7 @@ }, "logos-nix_308": { "inputs": { - "nixpkgs": "nixpkgs_311" + "nixpkgs": "nixpkgs_310" }, "locked": { "lastModified": 1774455309, @@ -8957,7 +8957,7 @@ }, "logos-nix_309": { "inputs": { - "nixpkgs": "nixpkgs_312" + "nixpkgs": "nixpkgs_311" }, "locked": { "lastModified": 1773955630, @@ -8975,7 +8975,7 @@ }, "logos-nix_31": { "inputs": { - "nixpkgs": "nixpkgs_32" + "nixpkgs": "nixpkgs_31" }, "locked": { "lastModified": 1773955630, @@ -8993,7 +8993,7 @@ }, "logos-nix_310": { "inputs": { - "nixpkgs": "nixpkgs_313" + "nixpkgs": "nixpkgs_312" }, "locked": { "lastModified": 1773955630, @@ -9011,7 +9011,7 @@ }, "logos-nix_311": { "inputs": { - "nixpkgs": "nixpkgs_314" + "nixpkgs": "nixpkgs_313" }, "locked": { "lastModified": 1774455309, @@ -9029,7 +9029,7 @@ }, "logos-nix_312": { "inputs": { - "nixpkgs": "nixpkgs_315" + "nixpkgs": "nixpkgs_314" }, "locked": { "lastModified": 1774455309, @@ -9047,7 +9047,7 @@ }, "logos-nix_313": { "inputs": { - "nixpkgs": "nixpkgs_316" + "nixpkgs": "nixpkgs_315" }, "locked": { "lastModified": 1773955630, @@ -9065,7 +9065,7 @@ }, "logos-nix_314": { "inputs": { - "nixpkgs": "nixpkgs_317" + "nixpkgs": "nixpkgs_316" }, "locked": { "lastModified": 1773955630, @@ -9083,7 +9083,7 @@ }, "logos-nix_315": { "inputs": { - "nixpkgs": "nixpkgs_318" + "nixpkgs": "nixpkgs_317" }, "locked": { "lastModified": 1773955630, @@ -9101,7 +9101,7 @@ }, "logos-nix_316": { "inputs": { - "nixpkgs": "nixpkgs_319" + "nixpkgs": "nixpkgs_318" }, "locked": { "lastModified": 1773955630, @@ -9119,7 +9119,7 @@ }, "logos-nix_317": { "inputs": { - "nixpkgs": "nixpkgs_320" + "nixpkgs": "nixpkgs_319" }, "locked": { "lastModified": 1774455309, @@ -9137,7 +9137,7 @@ }, "logos-nix_318": { "inputs": { - "nixpkgs": "nixpkgs_321" + "nixpkgs": "nixpkgs_320" }, "locked": { "lastModified": 1773955630, @@ -9155,7 +9155,7 @@ }, "logos-nix_319": { "inputs": { - "nixpkgs": "nixpkgs_322" + "nixpkgs": "nixpkgs_321" }, "locked": { "lastModified": 1773955630, @@ -9173,7 +9173,7 @@ }, "logos-nix_32": { "inputs": { - "nixpkgs": "nixpkgs_33" + "nixpkgs": "nixpkgs_32" }, "locked": { "lastModified": 1773955630, @@ -9191,7 +9191,7 @@ }, "logos-nix_320": { "inputs": { - "nixpkgs": "nixpkgs_323" + "nixpkgs": "nixpkgs_322" }, "locked": { "lastModified": 1774455309, @@ -9209,7 +9209,7 @@ }, "logos-nix_321": { "inputs": { - "nixpkgs": "nixpkgs_324" + "nixpkgs": "nixpkgs_323" }, "locked": { "lastModified": 1773955630, @@ -9227,7 +9227,7 @@ }, "logos-nix_322": { "inputs": { - "nixpkgs": "nixpkgs_325" + "nixpkgs": "nixpkgs_324" }, "locked": { "lastModified": 1774455309, @@ -9245,7 +9245,7 @@ }, "logos-nix_323": { "inputs": { - "nixpkgs": "nixpkgs_326" + "nixpkgs": "nixpkgs_325" }, "locked": { "lastModified": 1773955630, @@ -9263,7 +9263,7 @@ }, "logos-nix_324": { "inputs": { - "nixpkgs": "nixpkgs_327" + "nixpkgs": "nixpkgs_326" }, "locked": { "lastModified": 1774455309, @@ -9281,7 +9281,7 @@ }, "logos-nix_325": { "inputs": { - "nixpkgs": "nixpkgs_328" + "nixpkgs": "nixpkgs_327" }, "locked": { "lastModified": 1773955630, @@ -9299,7 +9299,7 @@ }, "logos-nix_326": { "inputs": { - "nixpkgs": "nixpkgs_329" + "nixpkgs": "nixpkgs_328" }, "locked": { "lastModified": 1774455309, @@ -9317,7 +9317,7 @@ }, "logos-nix_327": { "inputs": { - "nixpkgs": "nixpkgs_330" + "nixpkgs": "nixpkgs_329" }, "locked": { "lastModified": 1773955630, @@ -9335,7 +9335,7 @@ }, "logos-nix_328": { "inputs": { - "nixpkgs": "nixpkgs_331" + "nixpkgs": "nixpkgs_330" }, "locked": { "lastModified": 1774455309, @@ -9353,7 +9353,7 @@ }, "logos-nix_329": { "inputs": { - "nixpkgs": "nixpkgs_332" + "nixpkgs": "nixpkgs_331" }, "locked": { "lastModified": 1773955630, @@ -9371,7 +9371,7 @@ }, "logos-nix_33": { "inputs": { - "nixpkgs": "nixpkgs_34" + "nixpkgs": "nixpkgs_33" }, "locked": { "lastModified": 1774455309, @@ -9389,7 +9389,7 @@ }, "logos-nix_330": { "inputs": { - "nixpkgs": "nixpkgs_333" + "nixpkgs": "nixpkgs_332" }, "locked": { "lastModified": 1773955630, @@ -9407,7 +9407,7 @@ }, "logos-nix_331": { "inputs": { - "nixpkgs": "nixpkgs_334" + "nixpkgs": "nixpkgs_333" }, "locked": { "lastModified": 1774455309, @@ -9425,7 +9425,7 @@ }, "logos-nix_332": { "inputs": { - "nixpkgs": "nixpkgs_335" + "nixpkgs": "nixpkgs_334" }, "locked": { "lastModified": 1773955630, @@ -9443,7 +9443,7 @@ }, "logos-nix_333": { "inputs": { - "nixpkgs": "nixpkgs_336" + "nixpkgs": "nixpkgs_335" }, "locked": { "lastModified": 1773955630, @@ -9461,7 +9461,7 @@ }, "logos-nix_334": { "inputs": { - "nixpkgs": "nixpkgs_337" + "nixpkgs": "nixpkgs_336" }, "locked": { "lastModified": 1773955630, @@ -9479,7 +9479,7 @@ }, "logos-nix_335": { "inputs": { - "nixpkgs": "nixpkgs_338" + "nixpkgs": "nixpkgs_337" }, "locked": { "lastModified": 1773955630, @@ -9497,7 +9497,7 @@ }, "logos-nix_336": { "inputs": { - "nixpkgs": "nixpkgs_339" + "nixpkgs": "nixpkgs_338" }, "locked": { "lastModified": 1774455309, @@ -9515,7 +9515,7 @@ }, "logos-nix_337": { "inputs": { - "nixpkgs": "nixpkgs_340" + "nixpkgs": "nixpkgs_339" }, "locked": { "lastModified": 1773955630, @@ -9533,7 +9533,7 @@ }, "logos-nix_338": { "inputs": { - "nixpkgs": "nixpkgs_341" + "nixpkgs": "nixpkgs_340" }, "locked": { "lastModified": 1774455309, @@ -9551,7 +9551,7 @@ }, "logos-nix_339": { "inputs": { - "nixpkgs": "nixpkgs_342" + "nixpkgs": "nixpkgs_341" }, "locked": { "lastModified": 1774455309, @@ -9569,7 +9569,7 @@ }, "logos-nix_34": { "inputs": { - "nixpkgs": "nixpkgs_35" + "nixpkgs": "nixpkgs_34" }, "locked": { "lastModified": 1773955630, @@ -9587,7 +9587,7 @@ }, "logos-nix_340": { "inputs": { - "nixpkgs": "nixpkgs_343" + "nixpkgs": "nixpkgs_342" }, "locked": { "lastModified": 1773955630, @@ -9605,7 +9605,7 @@ }, "logos-nix_341": { "inputs": { - "nixpkgs": "nixpkgs_344" + "nixpkgs": "nixpkgs_343" }, "locked": { "lastModified": 1773955630, @@ -9623,7 +9623,7 @@ }, "logos-nix_342": { "inputs": { - "nixpkgs": "nixpkgs_345" + "nixpkgs": "nixpkgs_344" }, "locked": { "lastModified": 1773955630, @@ -9641,7 +9641,7 @@ }, "logos-nix_343": { "inputs": { - "nixpkgs": "nixpkgs_346" + "nixpkgs": "nixpkgs_345" }, "locked": { "lastModified": 1773955630, @@ -9659,7 +9659,7 @@ }, "logos-nix_344": { "inputs": { - "nixpkgs": "nixpkgs_347" + "nixpkgs": "nixpkgs_346" }, "locked": { "lastModified": 1773955630, @@ -9677,7 +9677,7 @@ }, "logos-nix_345": { "inputs": { - "nixpkgs": "nixpkgs_348" + "nixpkgs": "nixpkgs_347" }, "locked": { "lastModified": 1774455309, @@ -9695,7 +9695,7 @@ }, "logos-nix_346": { "inputs": { - "nixpkgs": "nixpkgs_349" + "nixpkgs": "nixpkgs_348" }, "locked": { "lastModified": 1773955630, @@ -9713,7 +9713,7 @@ }, "logos-nix_347": { "inputs": { - "nixpkgs": "nixpkgs_350" + "nixpkgs": "nixpkgs_349" }, "locked": { "lastModified": 1774455309, @@ -9731,7 +9731,7 @@ }, "logos-nix_348": { "inputs": { - "nixpkgs": "nixpkgs_351" + "nixpkgs": "nixpkgs_350" }, "locked": { "lastModified": 1774455309, @@ -9749,7 +9749,7 @@ }, "logos-nix_349": { "inputs": { - "nixpkgs": "nixpkgs_352" + "nixpkgs": "nixpkgs_351" }, "locked": { "lastModified": 1773955630, @@ -9767,7 +9767,7 @@ }, "logos-nix_35": { "inputs": { - "nixpkgs": "nixpkgs_36" + "nixpkgs": "nixpkgs_35" }, "locked": { "lastModified": 1774455309, @@ -9785,7 +9785,7 @@ }, "logos-nix_350": { "inputs": { - "nixpkgs": "nixpkgs_353" + "nixpkgs": "nixpkgs_352" }, "locked": { "lastModified": 1773955630, @@ -9803,7 +9803,7 @@ }, "logos-nix_351": { "inputs": { - "nixpkgs": "nixpkgs_354" + "nixpkgs": "nixpkgs_353" }, "locked": { "lastModified": 1774455309, @@ -9821,7 +9821,7 @@ }, "logos-nix_352": { "inputs": { - "nixpkgs": "nixpkgs_355" + "nixpkgs": "nixpkgs_354" }, "locked": { "lastModified": 1774455309, @@ -9839,7 +9839,7 @@ }, "logos-nix_353": { "inputs": { - "nixpkgs": "nixpkgs_356" + "nixpkgs": "nixpkgs_355" }, "locked": { "lastModified": 1773955630, @@ -9857,7 +9857,7 @@ }, "logos-nix_354": { "inputs": { - "nixpkgs": "nixpkgs_357" + "nixpkgs": "nixpkgs_356" }, "locked": { "lastModified": 1773955630, @@ -9875,7 +9875,7 @@ }, "logos-nix_355": { "inputs": { - "nixpkgs": "nixpkgs_358" + "nixpkgs": "nixpkgs_357" }, "locked": { "lastModified": 1774455309, @@ -9893,7 +9893,7 @@ }, "logos-nix_356": { "inputs": { - "nixpkgs": "nixpkgs_359" + "nixpkgs": "nixpkgs_358" }, "locked": { "lastModified": 1774455309, @@ -9911,7 +9911,7 @@ }, "logos-nix_357": { "inputs": { - "nixpkgs": "nixpkgs_360" + "nixpkgs": "nixpkgs_359" }, "locked": { "lastModified": 1773955630, @@ -9929,7 +9929,7 @@ }, "logos-nix_358": { "inputs": { - "nixpkgs": "nixpkgs_361" + "nixpkgs": "nixpkgs_360" }, "locked": { "lastModified": 1773955630, @@ -9947,7 +9947,7 @@ }, "logos-nix_359": { "inputs": { - "nixpkgs": "nixpkgs_362" + "nixpkgs": "nixpkgs_361" }, "locked": { "lastModified": 1773955630, @@ -9965,7 +9965,7 @@ }, "logos-nix_36": { "inputs": { - "nixpkgs": "nixpkgs_37" + "nixpkgs": "nixpkgs_36" }, "locked": { "lastModified": 1774455309, @@ -9983,7 +9983,7 @@ }, "logos-nix_360": { "inputs": { - "nixpkgs": "nixpkgs_363" + "nixpkgs": "nixpkgs_362" }, "locked": { "lastModified": 1773955630, @@ -10001,7 +10001,7 @@ }, "logos-nix_361": { "inputs": { - "nixpkgs": "nixpkgs_364" + "nixpkgs": "nixpkgs_363" }, "locked": { "lastModified": 1774455309, @@ -10019,7 +10019,7 @@ }, "logos-nix_362": { "inputs": { - "nixpkgs": "nixpkgs_365" + "nixpkgs": "nixpkgs_364" }, "locked": { "lastModified": 1773955630, @@ -10037,7 +10037,7 @@ }, "logos-nix_363": { "inputs": { - "nixpkgs": "nixpkgs_366" + "nixpkgs": "nixpkgs_365" }, "locked": { "lastModified": 1773955630, @@ -10055,7 +10055,7 @@ }, "logos-nix_364": { "inputs": { - "nixpkgs": "nixpkgs_367" + "nixpkgs": "nixpkgs_366" }, "locked": { "lastModified": 1774455309, @@ -10073,7 +10073,7 @@ }, "logos-nix_365": { "inputs": { - "nixpkgs": "nixpkgs_368" + "nixpkgs": "nixpkgs_367" }, "locked": { "lastModified": 1773955630, @@ -10091,7 +10091,7 @@ }, "logos-nix_366": { "inputs": { - "nixpkgs": "nixpkgs_369" + "nixpkgs": "nixpkgs_368" }, "locked": { "lastModified": 1774455309, @@ -10109,7 +10109,7 @@ }, "logos-nix_367": { "inputs": { - "nixpkgs": "nixpkgs_370" + "nixpkgs": "nixpkgs_369" }, "locked": { "lastModified": 1774455309, @@ -10127,7 +10127,7 @@ }, "logos-nix_368": { "inputs": { - "nixpkgs": "nixpkgs_371" + "nixpkgs": "nixpkgs_370" }, "locked": { "lastModified": 1773955630, @@ -10145,7 +10145,7 @@ }, "logos-nix_369": { "inputs": { - "nixpkgs": "nixpkgs_372" + "nixpkgs": "nixpkgs_371" }, "locked": { "lastModified": 1773955630, @@ -10163,7 +10163,7 @@ }, "logos-nix_37": { "inputs": { - "nixpkgs": "nixpkgs_38" + "nixpkgs": "nixpkgs_37" }, "locked": { "lastModified": 1773955630, @@ -10181,7 +10181,7 @@ }, "logos-nix_370": { "inputs": { - "nixpkgs": "nixpkgs_373" + "nixpkgs": "nixpkgs_372" }, "locked": { "lastModified": 1773955630, @@ -10199,7 +10199,7 @@ }, "logos-nix_371": { "inputs": { - "nixpkgs": "nixpkgs_374" + "nixpkgs": "nixpkgs_373" }, "locked": { "lastModified": 1773955630, @@ -10217,7 +10217,7 @@ }, "logos-nix_372": { "inputs": { - "nixpkgs": "nixpkgs_375" + "nixpkgs": "nixpkgs_374" }, "locked": { "lastModified": 1774455309, @@ -10235,7 +10235,7 @@ }, "logos-nix_373": { "inputs": { - "nixpkgs": "nixpkgs_376" + "nixpkgs": "nixpkgs_375" }, "locked": { "lastModified": 1774455309, @@ -10253,7 +10253,7 @@ }, "logos-nix_374": { "inputs": { - "nixpkgs": "nixpkgs_377" + "nixpkgs": "nixpkgs_376" }, "locked": { "lastModified": 1773955630, @@ -10271,7 +10271,7 @@ }, "logos-nix_375": { "inputs": { - "nixpkgs": "nixpkgs_378" + "nixpkgs": "nixpkgs_377" }, "locked": { "lastModified": 1774455309, @@ -10289,7 +10289,7 @@ }, "logos-nix_376": { "inputs": { - "nixpkgs": "nixpkgs_379" + "nixpkgs": "nixpkgs_378" }, "locked": { "lastModified": 1773955630, @@ -10307,7 +10307,7 @@ }, "logos-nix_377": { "inputs": { - "nixpkgs": "nixpkgs_380" + "nixpkgs": "nixpkgs_379" }, "locked": { "lastModified": 1774455309, @@ -10325,7 +10325,7 @@ }, "logos-nix_378": { "inputs": { - "nixpkgs": "nixpkgs_381" + "nixpkgs": "nixpkgs_380" }, "locked": { "lastModified": 1774455309, @@ -10343,7 +10343,7 @@ }, "logos-nix_379": { "inputs": { - "nixpkgs": "nixpkgs_382" + "nixpkgs": "nixpkgs_381" }, "locked": { "lastModified": 1773955630, @@ -10361,7 +10361,7 @@ }, "logos-nix_38": { "inputs": { - "nixpkgs": "nixpkgs_39" + "nixpkgs": "nixpkgs_38" }, "locked": { "lastModified": 1773955630, @@ -10379,7 +10379,7 @@ }, "logos-nix_380": { "inputs": { - "nixpkgs": "nixpkgs_383" + "nixpkgs": "nixpkgs_382" }, "locked": { "lastModified": 1773955630, @@ -10397,7 +10397,7 @@ }, "logos-nix_381": { "inputs": { - "nixpkgs": "nixpkgs_384" + "nixpkgs": "nixpkgs_383" }, "locked": { "lastModified": 1774455309, @@ -10415,7 +10415,7 @@ }, "logos-nix_382": { "inputs": { - "nixpkgs": "nixpkgs_385" + "nixpkgs": "nixpkgs_384" }, "locked": { "lastModified": 1774455309, @@ -10433,7 +10433,7 @@ }, "logos-nix_383": { "inputs": { - "nixpkgs": "nixpkgs_386" + "nixpkgs": "nixpkgs_385" }, "locked": { "lastModified": 1773955630, @@ -10451,7 +10451,7 @@ }, "logos-nix_384": { "inputs": { - "nixpkgs": "nixpkgs_387" + "nixpkgs": "nixpkgs_386" }, "locked": { "lastModified": 1773955630, @@ -10469,7 +10469,7 @@ }, "logos-nix_385": { "inputs": { - "nixpkgs": "nixpkgs_388" + "nixpkgs": "nixpkgs_387" }, "locked": { "lastModified": 1774455309, @@ -10487,7 +10487,7 @@ }, "logos-nix_386": { "inputs": { - "nixpkgs": "nixpkgs_389" + "nixpkgs": "nixpkgs_388" }, "locked": { "lastModified": 1774455309, @@ -10505,7 +10505,7 @@ }, "logos-nix_387": { "inputs": { - "nixpkgs": "nixpkgs_390" + "nixpkgs": "nixpkgs_389" }, "locked": { "lastModified": 1773955630, @@ -10523,7 +10523,7 @@ }, "logos-nix_388": { "inputs": { - "nixpkgs": "nixpkgs_391" + "nixpkgs": "nixpkgs_390" }, "locked": { "lastModified": 1773955630, @@ -10541,7 +10541,7 @@ }, "logos-nix_389": { "inputs": { - "nixpkgs": "nixpkgs_392" + "nixpkgs": "nixpkgs_391" }, "locked": { "lastModified": 1773955630, @@ -10559,7 +10559,7 @@ }, "logos-nix_39": { "inputs": { - "nixpkgs": "nixpkgs_40" + "nixpkgs": "nixpkgs_39" }, "locked": { "lastModified": 1774455309, @@ -10577,7 +10577,7 @@ }, "logos-nix_390": { "inputs": { - "nixpkgs": "nixpkgs_393" + "nixpkgs": "nixpkgs_392" }, "locked": { "lastModified": 1773955630, @@ -10595,7 +10595,7 @@ }, "logos-nix_391": { "inputs": { - "nixpkgs": "nixpkgs_394" + "nixpkgs": "nixpkgs_393" }, "locked": { "lastModified": 1774455309, @@ -10613,7 +10613,7 @@ }, "logos-nix_392": { "inputs": { - "nixpkgs": "nixpkgs_395" + "nixpkgs": "nixpkgs_394" }, "locked": { "lastModified": 1773955630, @@ -10631,7 +10631,7 @@ }, "logos-nix_393": { "inputs": { - "nixpkgs": "nixpkgs_396" + "nixpkgs": "nixpkgs_395" }, "locked": { "lastModified": 1773955630, @@ -10649,7 +10649,7 @@ }, "logos-nix_394": { "inputs": { - "nixpkgs": "nixpkgs_397" + "nixpkgs": "nixpkgs_396" }, "locked": { "lastModified": 1774455309, @@ -10667,7 +10667,7 @@ }, "logos-nix_395": { "inputs": { - "nixpkgs": "nixpkgs_398" + "nixpkgs": "nixpkgs_397" }, "locked": { "lastModified": 1773955630, @@ -10685,7 +10685,7 @@ }, "logos-nix_396": { "inputs": { - "nixpkgs": "nixpkgs_399" + "nixpkgs": "nixpkgs_398" }, "locked": { "lastModified": 1773955630, @@ -10703,7 +10703,7 @@ }, "logos-nix_4": { "inputs": { - "nixpkgs": "nixpkgs_5" + "nixpkgs": "nixpkgs_4" }, "locked": { "lastModified": 1773955630, @@ -10721,7 +10721,7 @@ }, "logos-nix_40": { "inputs": { - "nixpkgs": "nixpkgs_41" + "nixpkgs": "nixpkgs_40" }, "locked": { "lastModified": 1774455309, @@ -10739,7 +10739,7 @@ }, "logos-nix_41": { "inputs": { - "nixpkgs": "nixpkgs_42" + "nixpkgs": "nixpkgs_41" }, "locked": { "lastModified": 1773955630, @@ -10757,7 +10757,7 @@ }, "logos-nix_42": { "inputs": { - "nixpkgs": "nixpkgs_43" + "nixpkgs": "nixpkgs_42" }, "locked": { "lastModified": 1773955630, @@ -10775,7 +10775,7 @@ }, "logos-nix_43": { "inputs": { - "nixpkgs": "nixpkgs_44" + "nixpkgs": "nixpkgs_43" }, "locked": { "lastModified": 1774455309, @@ -10793,7 +10793,7 @@ }, "logos-nix_44": { "inputs": { - "nixpkgs": "nixpkgs_45" + "nixpkgs": "nixpkgs_44" }, "locked": { "lastModified": 1774455309, @@ -10811,7 +10811,7 @@ }, "logos-nix_45": { "inputs": { - "nixpkgs": "nixpkgs_46" + "nixpkgs": "nixpkgs_45" }, "locked": { "lastModified": 1773955630, @@ -10829,7 +10829,7 @@ }, "logos-nix_46": { "inputs": { - "nixpkgs": "nixpkgs_47" + "nixpkgs": "nixpkgs_46" }, "locked": { "lastModified": 1773955630, @@ -10847,7 +10847,7 @@ }, "logos-nix_47": { "inputs": { - "nixpkgs": "nixpkgs_48" + "nixpkgs": "nixpkgs_47" }, "locked": { "lastModified": 1773955630, @@ -10865,7 +10865,7 @@ }, "logos-nix_48": { "inputs": { - "nixpkgs": "nixpkgs_49" + "nixpkgs": "nixpkgs_48" }, "locked": { "lastModified": 1773955630, @@ -10883,7 +10883,7 @@ }, "logos-nix_49": { "inputs": { - "nixpkgs": "nixpkgs_50" + "nixpkgs": "nixpkgs_49" }, "locked": { "lastModified": 1774455309, @@ -10901,7 +10901,7 @@ }, "logos-nix_5": { "inputs": { - "nixpkgs": "nixpkgs_6" + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1774455309, @@ -10919,7 +10919,7 @@ }, "logos-nix_50": { "inputs": { - "nixpkgs": "nixpkgs_51" + "nixpkgs": "nixpkgs_50" }, "locked": { "lastModified": 1773955630, @@ -10937,7 +10937,7 @@ }, "logos-nix_51": { "inputs": { - "nixpkgs": "nixpkgs_52" + "nixpkgs": "nixpkgs_51" }, "locked": { "lastModified": 1773955630, @@ -10955,7 +10955,7 @@ }, "logos-nix_52": { "inputs": { - "nixpkgs": "nixpkgs_53" + "nixpkgs": "nixpkgs_52" }, "locked": { "lastModified": 1774455309, @@ -10973,7 +10973,7 @@ }, "logos-nix_53": { "inputs": { - "nixpkgs": "nixpkgs_54" + "nixpkgs": "nixpkgs_53" }, "locked": { "lastModified": 1773955630, @@ -10991,7 +10991,7 @@ }, "logos-nix_54": { "inputs": { - "nixpkgs": "nixpkgs_55" + "nixpkgs": "nixpkgs_54" }, "locked": { "lastModified": 1774455309, @@ -11009,7 +11009,7 @@ }, "logos-nix_55": { "inputs": { - "nixpkgs": "nixpkgs_56" + "nixpkgs": "nixpkgs_55" }, "locked": { "lastModified": 1773955630, @@ -11027,7 +11027,7 @@ }, "logos-nix_56": { "inputs": { - "nixpkgs": "nixpkgs_57" + "nixpkgs": "nixpkgs_56" }, "locked": { "lastModified": 1774455309, @@ -11045,7 +11045,7 @@ }, "logos-nix_57": { "inputs": { - "nixpkgs": "nixpkgs_58" + "nixpkgs": "nixpkgs_57" }, "locked": { "lastModified": 1773955630, @@ -11063,7 +11063,7 @@ }, "logos-nix_58": { "inputs": { - "nixpkgs": "nixpkgs_59" + "nixpkgs": "nixpkgs_58" }, "locked": { "lastModified": 1774455309, @@ -11081,7 +11081,7 @@ }, "logos-nix_59": { "inputs": { - "nixpkgs": "nixpkgs_60" + "nixpkgs": "nixpkgs_59" }, "locked": { "lastModified": 1773955630, @@ -11099,7 +11099,7 @@ }, "logos-nix_6": { "inputs": { - "nixpkgs": "nixpkgs_7" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1773955630, @@ -11117,7 +11117,7 @@ }, "logos-nix_60": { "inputs": { - "nixpkgs": "nixpkgs_61" + "nixpkgs": "nixpkgs_60" }, "locked": { "lastModified": 1774455309, @@ -11135,7 +11135,7 @@ }, "logos-nix_61": { "inputs": { - "nixpkgs": "nixpkgs_62" + "nixpkgs": "nixpkgs_61" }, "locked": { "lastModified": 1773955630, @@ -11153,7 +11153,7 @@ }, "logos-nix_62": { "inputs": { - "nixpkgs": "nixpkgs_63" + "nixpkgs": "nixpkgs_62" }, "locked": { "lastModified": 1773955630, @@ -11171,7 +11171,7 @@ }, "logos-nix_63": { "inputs": { - "nixpkgs": "nixpkgs_64" + "nixpkgs": "nixpkgs_63" }, "locked": { "lastModified": 1774455309, @@ -11189,7 +11189,7 @@ }, "logos-nix_64": { "inputs": { - "nixpkgs": "nixpkgs_65" + "nixpkgs": "nixpkgs_64" }, "locked": { "lastModified": 1773955630, @@ -11207,7 +11207,7 @@ }, "logos-nix_65": { "inputs": { - "nixpkgs": "nixpkgs_66" + "nixpkgs": "nixpkgs_65" }, "locked": { "lastModified": 1773955630, @@ -11225,7 +11225,7 @@ }, "logos-nix_66": { "inputs": { - "nixpkgs": "nixpkgs_67" + "nixpkgs": "nixpkgs_66" }, "locked": { "lastModified": 1773955630, @@ -11243,7 +11243,7 @@ }, "logos-nix_67": { "inputs": { - "nixpkgs": "nixpkgs_68" + "nixpkgs": "nixpkgs_67" }, "locked": { "lastModified": 1773955630, @@ -11261,7 +11261,7 @@ }, "logos-nix_68": { "inputs": { - "nixpkgs": "nixpkgs_69" + "nixpkgs": "nixpkgs_68" }, "locked": { "lastModified": 1774455309, @@ -11279,7 +11279,7 @@ }, "logos-nix_69": { "inputs": { - "nixpkgs": "nixpkgs_70" + "nixpkgs": "nixpkgs_69" }, "locked": { "lastModified": 1773955630, @@ -11297,7 +11297,7 @@ }, "logos-nix_7": { "inputs": { - "nixpkgs": "nixpkgs_8" + "nixpkgs": "nixpkgs_7" }, "locked": { "lastModified": 1774455309, @@ -11315,7 +11315,7 @@ }, "logos-nix_70": { "inputs": { - "nixpkgs": "nixpkgs_71" + "nixpkgs": "nixpkgs_70" }, "locked": { "lastModified": 1774455309, @@ -11333,7 +11333,7 @@ }, "logos-nix_71": { "inputs": { - "nixpkgs": "nixpkgs_72" + "nixpkgs": "nixpkgs_71" }, "locked": { "lastModified": 1774455309, @@ -11351,7 +11351,7 @@ }, "logos-nix_72": { "inputs": { - "nixpkgs": "nixpkgs_73" + "nixpkgs": "nixpkgs_72" }, "locked": { "lastModified": 1773955630, @@ -11369,7 +11369,7 @@ }, "logos-nix_73": { "inputs": { - "nixpkgs": "nixpkgs_74" + "nixpkgs": "nixpkgs_73" }, "locked": { "lastModified": 1773955630, @@ -11387,7 +11387,7 @@ }, "logos-nix_74": { "inputs": { - "nixpkgs": "nixpkgs_75" + "nixpkgs": "nixpkgs_74" }, "locked": { "lastModified": 1773955630, @@ -11405,7 +11405,7 @@ }, "logos-nix_75": { "inputs": { - "nixpkgs": "nixpkgs_76" + "nixpkgs": "nixpkgs_75" }, "locked": { "lastModified": 1773955630, @@ -11423,7 +11423,7 @@ }, "logos-nix_76": { "inputs": { - "nixpkgs": "nixpkgs_77" + "nixpkgs": "nixpkgs_76" }, "locked": { "lastModified": 1773955630, @@ -11441,7 +11441,7 @@ }, "logos-nix_77": { "inputs": { - "nixpkgs": "nixpkgs_78" + "nixpkgs": "nixpkgs_77" }, "locked": { "lastModified": 1774455309, @@ -11459,7 +11459,7 @@ }, "logos-nix_78": { "inputs": { - "nixpkgs": "nixpkgs_79" + "nixpkgs": "nixpkgs_78" }, "locked": { "lastModified": 1773955630, @@ -11477,7 +11477,7 @@ }, "logos-nix_79": { "inputs": { - "nixpkgs": "nixpkgs_80" + "nixpkgs": "nixpkgs_79" }, "locked": { "lastModified": 1774455309, @@ -11495,7 +11495,7 @@ }, "logos-nix_8": { "inputs": { - "nixpkgs": "nixpkgs_9" + "nixpkgs": "nixpkgs_8" }, "locked": { "lastModified": 1774455309, @@ -11513,7 +11513,7 @@ }, "logos-nix_80": { "inputs": { - "nixpkgs": "nixpkgs_81" + "nixpkgs": "nixpkgs_80" }, "locked": { "lastModified": 1774455309, @@ -11531,7 +11531,7 @@ }, "logos-nix_81": { "inputs": { - "nixpkgs": "nixpkgs_82" + "nixpkgs": "nixpkgs_81" }, "locked": { "lastModified": 1773955630, @@ -11549,7 +11549,7 @@ }, "logos-nix_82": { "inputs": { - "nixpkgs": "nixpkgs_83" + "nixpkgs": "nixpkgs_82" }, "locked": { "lastModified": 1773955630, @@ -11567,7 +11567,7 @@ }, "logos-nix_83": { "inputs": { - "nixpkgs": "nixpkgs_84" + "nixpkgs": "nixpkgs_83" }, "locked": { "lastModified": 1774455309, @@ -11585,7 +11585,7 @@ }, "logos-nix_84": { "inputs": { - "nixpkgs": "nixpkgs_85" + "nixpkgs": "nixpkgs_84" }, "locked": { "lastModified": 1774455309, @@ -11603,7 +11603,7 @@ }, "logos-nix_85": { "inputs": { - "nixpkgs": "nixpkgs_86" + "nixpkgs": "nixpkgs_85" }, "locked": { "lastModified": 1773955630, @@ -11621,7 +11621,7 @@ }, "logos-nix_86": { "inputs": { - "nixpkgs": "nixpkgs_87" + "nixpkgs": "nixpkgs_86" }, "locked": { "lastModified": 1773955630, @@ -11639,7 +11639,7 @@ }, "logos-nix_87": { "inputs": { - "nixpkgs": "nixpkgs_88" + "nixpkgs": "nixpkgs_87" }, "locked": { "lastModified": 1774455309, @@ -11657,7 +11657,7 @@ }, "logos-nix_88": { "inputs": { - "nixpkgs": "nixpkgs_89" + "nixpkgs": "nixpkgs_88" }, "locked": { "lastModified": 1774455309, @@ -11675,7 +11675,7 @@ }, "logos-nix_89": { "inputs": { - "nixpkgs": "nixpkgs_90" + "nixpkgs": "nixpkgs_89" }, "locked": { "lastModified": 1773955630, @@ -11693,7 +11693,7 @@ }, "logos-nix_9": { "inputs": { - "nixpkgs": "nixpkgs_10" + "nixpkgs": "nixpkgs_9" }, "locked": { "lastModified": 1774455309, @@ -11711,7 +11711,7 @@ }, "logos-nix_90": { "inputs": { - "nixpkgs": "nixpkgs_91" + "nixpkgs": "nixpkgs_90" }, "locked": { "lastModified": 1773955630, @@ -11729,7 +11729,7 @@ }, "logos-nix_91": { "inputs": { - "nixpkgs": "nixpkgs_92" + "nixpkgs": "nixpkgs_91" }, "locked": { "lastModified": 1773955630, @@ -11747,7 +11747,7 @@ }, "logos-nix_92": { "inputs": { - "nixpkgs": "nixpkgs_93" + "nixpkgs": "nixpkgs_92" }, "locked": { "lastModified": 1773955630, @@ -11765,7 +11765,7 @@ }, "logos-nix_93": { "inputs": { - "nixpkgs": "nixpkgs_94" + "nixpkgs": "nixpkgs_93" }, "locked": { "lastModified": 1774455309, @@ -11783,7 +11783,7 @@ }, "logos-nix_94": { "inputs": { - "nixpkgs": "nixpkgs_95" + "nixpkgs": "nixpkgs_94" }, "locked": { "lastModified": 1773955630, @@ -11801,7 +11801,7 @@ }, "logos-nix_95": { "inputs": { - "nixpkgs": "nixpkgs_96" + "nixpkgs": "nixpkgs_95" }, "locked": { "lastModified": 1773955630, @@ -11819,7 +11819,7 @@ }, "logos-nix_96": { "inputs": { - "nixpkgs": "nixpkgs_97" + "nixpkgs": "nixpkgs_96" }, "locked": { "lastModified": 1774455309, @@ -11837,7 +11837,7 @@ }, "logos-nix_97": { "inputs": { - "nixpkgs": "nixpkgs_98" + "nixpkgs": "nixpkgs_97" }, "locked": { "lastModified": 1773955630, @@ -11855,7 +11855,7 @@ }, "logos-nix_98": { "inputs": { - "nixpkgs": "nixpkgs_99" + "nixpkgs": "nixpkgs_98" }, "locked": { "lastModified": 1774455309, @@ -11873,7 +11873,7 @@ }, "logos-nix_99": { "inputs": { - "nixpkgs": "nixpkgs_100" + "nixpkgs": "nixpkgs_99" }, "locked": { "lastModified": 1774455309, @@ -20096,6 +20096,22 @@ "type": "github" } }, + "nixpkgs": { + "locked": { + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_10": { "locked": { "lastModified": 1759036355, @@ -20530,32 +20546,32 @@ }, "nixpkgs_124": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_125": { "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } @@ -23122,32 +23138,32 @@ }, "nixpkgs_270": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_271": { "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } @@ -25392,22 +25408,6 @@ "type": "github" } }, - "nixpkgs_399": { - "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "nixpkgs_4": { "locked": { "lastModified": 1759036355, @@ -26767,18 +26767,6 @@ "shared_wallet": "shared_wallet" } }, - "shared_wallet": { - "flake": false, - "locked": { - "path": "../shared/wallet", - "type": "path" - }, - "original": { - "path": "../shared/wallet", - "type": "path" - }, - "parent": [] - }, "rust-overlay": { "inputs": { "nixpkgs": [ @@ -26846,7 +26834,7 @@ }, "rust-rapidsnark": { "inputs": { - "nixpkgs": "nixpkgs_271" + "nixpkgs": "nixpkgs_270" }, "locked": { "lastModified": 1781090841, @@ -26862,6 +26850,18 @@ "rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f", "type": "github" } + }, + "shared_wallet": { + "flake": false, + "locked": { + "path": "../shared/wallet", + "type": "path" + }, + "original": { + "path": "../shared/wallet", + "type": "path" + }, + "parent": [] } }, "root": "root", diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 7f345e11..f237473e 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -14,7 +14,18 @@ # match the metadata.json `dependencies` entry so the builder can resolve # it as a module dependency. This revision exposes generic transaction # submission by deployed program ID. - logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80"; + logos_execution_zone = { + url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80"; + + # The module pins the monorepo at v0.2.0-rc6 (e37876a), which owns the + # xcrun wrapper in its flake.nix. Override that transitive input to the + # head of PR #629 (fix(macos): nuke xcrun cache) so the Metal/xcrun build + # works on macOS. Note: #629 branches off `dev`, so this also pulls dev's + # drift from rc6 — if the wallet_ffi ABI mismatches the module build, fall + # back to cherry-picking #629's one line onto e37876a and pin that commit. + inputs.logos-execution-zone.url = + "github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05"; + }; }; outputs = inputs@{ logos-module-builder, shared_wallet, ... }: From c0947e1917c38e4c2ed52ad87b2bb61263a503e3 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:44:20 +0200 Subject: [PATCH 05/14] fix(apps/wallet): use @loader_path rpath for the QML plugin on macOS The Logos.Wallet QML plugin was installed with an ELF "$ORIGIN" rpath. macOS dyld does not expand "$ORIGIN", so loading the plugin failed with Cannot load library liblogos_wallet_qmlplugin.dylib: Library not loaded: @rpath/liblogos_wallet_qml.dylib even though liblogos_wallet_qml.dylib sits in the same directory. Select the rpath per platform: @loader_path on Apple, $ORIGIN elsewhere, so @rpath/liblogos_wallet_qml.dylib resolves to the sibling library. --- apps/shared/wallet/CMakeLists.txt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 37f16630..896716b8 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -106,11 +106,18 @@ if(LOGOS_WALLET_BUILD_QML) Qt6::Quick Qt6::QuickControls2 ) + # Resolve the sibling liblogos_wallet_qml.dylib/.so next to the plugin. + # macOS dyld does not expand the ELF "$ORIGIN" token — it uses @loader_path. + if(APPLE) + set(wallet_qml_rpath "@loader_path") + else() + set(wallet_qml_rpath "$ORIGIN") + endif() 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" + INSTALL_RPATH "${wallet_qml_rpath}" ) set(wallet_qml_install_dir "lib/qml/Logos/Wallet") From 9b72b08c2d8a756aab746df047918835e9e67b77 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 20:18:09 -0300 Subject: [PATCH 06/14] feat(wallet): humanize shared wallet experience --- Cargo.lock | 11 + Cargo.toml | 1 + apps/amm/CMakeLists.txt | 35 + apps/amm/config/idl/amm-idl.json | 791 ++++++++++++++++++ apps/amm/config/idl/token-idl.json | 398 +++++++++ apps/amm/config/networks.json | 18 + apps/amm/flake.lock | 36 +- apps/amm/flake.nix | 13 +- apps/amm/metadata.json | 6 +- apps/amm/src/ActiveNetwork.cpp | 139 +++ apps/amm/src/ActiveNetwork.h | 36 + apps/amm/src/AmmUiBackend.cpp | 374 ++++++++- apps/amm/src/AmmUiBackend.h | 29 + apps/amm/src/AmmUiBackend.rep | 15 + apps/amm/src/WalletIdlDecoder.cpp | 100 +++ apps/amm/src/WalletIdlDecoder.h | 54 ++ apps/amm/tests/cpp/ActiveNetworkTest.cpp | 47 ++ apps/shared/wallet/CMakeLists.txt | 4 + apps/shared/wallet/qml/WalletControl.qml | 687 +++++++++++---- .../wallet/qml/internal/AccountDelegate.qml | 101 ++- .../shared/wallet/src/LogosWalletProvider.cpp | 367 +++++++- apps/shared/wallet/src/LogosWalletProvider.h | 6 + apps/shared/wallet/src/WalletAccountId.cpp | 56 ++ apps/shared/wallet/src/WalletAccountId.h | 5 + apps/shared/wallet/src/WalletAccountModel.cpp | 197 ++++- apps/shared/wallet/src/WalletAccountModel.h | 51 +- apps/shared/wallet/src/WalletController.cpp | 278 +++++- apps/shared/wallet/src/WalletController.h | 28 + apps/shared/wallet/src/WalletProvider.h | 12 + .../tests/cpp/LogosWalletProviderTest.cpp | 186 +++- .../wallet/tests/cpp/fixtures/logos_sdk.h | 42 + .../wallet/tests/qml/tst_WalletControl.qml | 329 +++++++- .../wallet/tests/support/FakeWalletProvider.h | 32 + flake.lock | 44 + flake.nix | 45 + tools/wallet-idl-decoder/Cargo.toml | 18 + .../include/wallet_idl_decoder.h | 15 + tools/wallet-idl-decoder/src/lib.rs | 278 ++++++ 38 files changed, 4613 insertions(+), 271 deletions(-) create mode 100644 apps/amm/config/idl/amm-idl.json create mode 100644 apps/amm/config/idl/token-idl.json create mode 100644 apps/amm/config/networks.json create mode 100644 apps/amm/src/ActiveNetwork.cpp create mode 100644 apps/amm/src/ActiveNetwork.h create mode 100644 apps/amm/src/WalletIdlDecoder.cpp create mode 100644 apps/amm/src/WalletIdlDecoder.h create mode 100644 apps/amm/tests/cpp/ActiveNetworkTest.cpp create mode 100644 apps/shared/wallet/src/WalletAccountId.cpp create mode 100644 apps/shared/wallet/src/WalletAccountId.h create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 tools/wallet-idl-decoder/Cargo.toml create mode 100644 tools/wallet-idl-decoder/include/wallet_idl_decoder.h create mode 100644 tools/wallet-idl-decoder/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7c3a1403..e9c33a89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4352,6 +4352,17 @@ dependencies = [ "libc", ] +[[package]] +name = "wallet-idl-decoder" +version = "0.1.0" +dependencies = [ + "base58", + "hex", + "serde", + "serde_json", + "spel-framework-core", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index c508ac99..be6b226e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "programs/integration_tests", "tools/idl-gen", "tools/risc0-packager", + "tools/wallet-idl-decoder", ] exclude = [ "programs/token/methods/guest", diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 38c8081c..d96a7f19 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -31,6 +31,10 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp + src/ActiveNetwork.h + src/ActiveNetwork.cpp + src/WalletIdlDecoder.h + src/WalletIdlDecoder.cpp FIND_PACKAGES Qt6Gui Qt6Network @@ -39,4 +43,35 @@ logos_module( Qt6::Network LINK_TARGETS logos_wallet_access + EXTERNAL_LIBS + wallet_idl_decoder ) + +set_source_files_properties( + config/idl/token-idl.json + PROPERTIES QT_RESOURCE_ALIAS "idl/token-idl.json" +) +set_source_files_properties( + config/idl/amm-idl.json + PROPERTIES QT_RESOURCE_ALIAS "idl/amm-idl.json" +) +qt_add_resources(amm_ui_module_plugin amm_ui_wallet_data + PREFIX "/amm" + FILES + config/networks.json + config/idl/token-idl.json + config/idl/amm-idl.json +) + +include(CTest) +if(BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Test) + add_executable(amm_active_network_test + tests/cpp/ActiveNetworkTest.cpp + src/ActiveNetwork.cpp + ) + set_target_properties(amm_active_network_test PROPERTIES AUTOMOC ON) + target_include_directories(amm_active_network_test PRIVATE src) + target_link_libraries(amm_active_network_test PRIVATE Qt6::Core Qt6::Test) + add_test(NAME amm_active_network COMMAND amm_active_network_test) +endif() diff --git a/apps/amm/config/idl/amm-idl.json b/apps/amm/config/idl/amm-idl.json new file mode 100644 index 00000000..dbe12603 --- /dev/null +++ b/apps/amm/config/idl/amm-idl.json @@ -0,0 +1,791 @@ +{ + "version": "0.1.0", + "name": "amm", + "instructions": [ + { + "name": "initialize", + "accounts": [ + { + "name": "config", + "writable": true, + "signer": false, + "init": true + } + ], + "args": [ + { + "name": "token_program_id", + "type": "program_id" + }, + { + "name": "twap_oracle_program_id", + "type": "program_id" + }, + { + "name": "authority", + "type": "account_id" + } + ] + }, + { + "name": "update_config", + "accounts": [ + { + "name": "config", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "authority", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "token_program_id", + "type": { + "option": "program_id" + } + }, + { + "name": "twap_oracle_program_id", + "type": { + "option": "program_id" + } + }, + { + "name": "new_authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "create_price_observations", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "current_tick_account", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "price_observations", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "window_duration", + "type": "u64" + } + ] + }, + { + "name": "create_oracle_price_account", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "oracle_price_account", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "window_duration", + "type": "u64" + } + ] + }, + { + "name": "new_definition", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "pool_definition_lp", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "lp_lock_holding", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "user_holding_a", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_lp", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "token_a_amount", + "type": "u128" + }, + { + "name": "token_b_amount", + "type": "u128" + }, + { + "name": "fees", + "type": "u128" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "add_liquidity", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "pool_definition_lp", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_a", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_lp", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "min_amount_liquidity", + "type": "u128" + }, + { + "name": "max_amount_to_add_token_a", + "type": "u128" + }, + { + "name": "max_amount_to_add_token_b", + "type": "u128" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "remove_liquidity", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "pool_definition_lp", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_lp", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "remove_liquidity_amount", + "type": "u128" + }, + { + "name": "min_amount_to_remove_token_a", + "type": "u128" + }, + { + "name": "min_amount_to_remove_token_b", + "type": "u128" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "swap_exact_input", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_a", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "swap_amount_in", + "type": "u128" + }, + { + "name": "min_amount_out", + "type": "u128" + }, + { + "name": "token_definition_id_in", + "type": "account_id" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "swap_exact_output", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_a", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "exact_amount_out", + "type": "u128" + }, + { + "name": "max_amount_in", + "type": "u128" + }, + { + "name": "token_definition_id_in", + "type": "account_id" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "sync_reserves", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "PoolDefinition", + "type": { + "kind": "struct", + "fields": [ + { + "name": "definition_token_a_id", + "type": "account_id" + }, + { + "name": "definition_token_b_id", + "type": "account_id" + }, + { + "name": "vault_a_id", + "type": "account_id" + }, + { + "name": "vault_b_id", + "type": "account_id" + }, + { + "name": "liquidity_pool_id", + "type": "account_id" + }, + { + "name": "liquidity_pool_supply", + "type": "u128" + }, + { + "name": "reserve_a", + "type": "u128" + }, + { + "name": "reserve_b", + "type": "u128" + }, + { + "name": "fees", + "type": "u128" + } + ] + } + }, + { + "name": "AmmConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "token_program_id", + "type": "program_id" + }, + { + "name": "twap_oracle_program_id", + "type": "program_id" + }, + { + "name": "authority", + "type": "account_id" + } + ] + } + }, + { + "name": "TokenDefinition", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Fungible", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "total_supply", + "type": "u128" + }, + { + "name": "metadata_id", + "type": { + "option": "account_id" + } + }, + { + "name": "authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "NonFungible", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "printable_supply", + "type": "u128" + }, + { + "name": "metadata_id", + "type": "account_id" + } + ] + } + ] + } + }, + { + "name": "TokenHolding", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Fungible", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "balance", + "type": "u128" + } + ] + }, + { + "name": "NftMaster", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "print_balance", + "type": "u128" + } + ] + }, + { + "name": "NftPrintedCopy", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "owned", + "type": "bool" + } + ] + } + ] + } + }, + { + "name": "TokenMetadata", + "type": { + "kind": "struct", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "standard", + "type": { + "defined": "MetadataStandard" + } + }, + { + "name": "uri", + "type": "string" + }, + { + "name": "creators", + "type": "string" + }, + { + "name": "primary_sale_date", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "MetadataStandard", + "kind": "enum", + "variants": [ + { + "name": "Simple" + }, + { + "name": "Expanded" + } + ] + } + ], + "instruction_type": "amm_core::Instruction" +} diff --git a/apps/amm/config/idl/token-idl.json b/apps/amm/config/idl/token-idl.json new file mode 100644 index 00000000..18510e49 --- /dev/null +++ b/apps/amm/config/idl/token-idl.json @@ -0,0 +1,398 @@ +{ + "version": "0.1.0", + "name": "token", + "instructions": [ + { + "name": "transfer", + "accounts": [ + { + "name": "sender", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "recipient", + "writable": true, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount_to_transfer", + "type": "u128" + } + ] + }, + { + "name": "new_fungible_definition", + "accounts": [ + { + "name": "definition_target_account", + "writable": true, + "signer": true, + "init": true + }, + { + "name": "holding_target_account", + "writable": true, + "signer": true, + "init": true + } + ], + "args": [ + { + "name": "name", + "type": "string" + }, + { + "name": "total_supply", + "type": "u128" + }, + { + "name": "mint_authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "new_definition_with_metadata", + "accounts": [ + { + "name": "definition_target_account", + "writable": true, + "signer": true, + "init": true + }, + { + "name": "holding_target_account", + "writable": true, + "signer": true, + "init": true + }, + { + "name": "metadata_target_account", + "writable": true, + "signer": true, + "init": true + } + ], + "args": [ + { + "name": "new_definition", + "type": { + "defined": "NewTokenDefinition" + } + }, + { + "name": "metadata", + "type": { + "defined": "Box" + } + } + ] + }, + { + "name": "initialize_account", + "accounts": [ + { + "name": "definition_account", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "account_to_initialize", + "writable": true, + "signer": true, + "init": true + } + ], + "args": [] + }, + { + "name": "burn", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_account", + "writable": true, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "amount_to_burn", + "type": "u128" + } + ] + }, + { + "name": "mint", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_account", + "writable": true, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount_to_mint", + "type": "u128" + } + ] + }, + { + "name": "mint_with_authority", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "authority_account", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "amount_to_mint", + "type": "u128" + } + ] + }, + { + "name": "set_authority", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "new_authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "set_authority_with_authority", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "authority_account", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "new_authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "print_nft", + "accounts": [ + { + "name": "master_account", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "printed_account", + "writable": true, + "signer": true, + "init": true + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "TokenDefinition", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Fungible", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "total_supply", + "type": "u128" + }, + { + "name": "metadata_id", + "type": { + "option": "account_id" + } + }, + { + "name": "authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "NonFungible", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "printable_supply", + "type": "u128" + }, + { + "name": "metadata_id", + "type": "account_id" + } + ] + } + ] + } + }, + { + "name": "TokenHolding", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Fungible", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "balance", + "type": "u128" + } + ] + }, + { + "name": "NftMaster", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "print_balance", + "type": "u128" + } + ] + }, + { + "name": "NftPrintedCopy", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "owned", + "type": "bool" + } + ] + } + ] + } + }, + { + "name": "TokenMetadata", + "type": { + "kind": "struct", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "standard", + "type": { + "defined": "MetadataStandard" + } + }, + { + "name": "uri", + "type": "string" + }, + { + "name": "creators", + "type": "string" + }, + { + "name": "primary_sale_date", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "MetadataStandard", + "kind": "enum", + "variants": [ + { + "name": "Simple" + }, + { + "name": "Expanded" + } + ] + } + ], + "instruction_type": "token_core::Instruction" +} diff --git a/apps/amm/config/networks.json b/apps/amm/config/networks.json new file mode 100644 index 00000000..75e03eba --- /dev/null +++ b/apps/amm/config/networks.json @@ -0,0 +1,18 @@ +{ + "testnet": { + "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a", + "ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07", + "tokenDefinitionIds": [ + "7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6", + "48c81cf032e601ca367fc9816b957dbf5c0e4c11cf7008e8f4581ec1a67aab42", + "159caef810ea545951b3bd913efe625ee45008c80865c330e72a72ed48b61649", + "75f33110b185717209e3955f228d4a4448801d0ce8ba438a4a268050eeff3f44", + "fbd107ca4bb66bc58f59ac2d32a759be3ee0fb453f8fecd1991c11837d9660c7", + "5547fcb72644d95a385d313b887a96be41ff263bce6150b49fd87276839822bf", + "fa43e74a97d79c5f907ff3edabda5ad89bfbd3b0922572e675d4ad3c7b6029c7", + "4f3231d8a01e1d79f163bc27fce0c860a4a2f6890280e9d135eafbde0d68ed79", + "fa32f354408857006f8ea396b0419823bd04436eadb2d273d2618a46b4793ed8", + "00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb" + ] + } +} diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index 8f79e67e..9fd15fe3 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,6 +1,22 @@ { "nodes": { "crane": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "ref": "v0.23.4", + "repo": "crane", + "type": "github" + } + }, + "crane_2": { "locked": { "lastModified": 1780532242, "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", @@ -2225,7 +2241,7 @@ }, "logos-execution-zone": { "inputs": { - "crane": "crane", + "crane": "crane_2", "logos-blockchain-circuits": "logos-blockchain-circuits", "logos-liblogos": "logos-liblogos_4", "nixpkgs": [ @@ -25408,6 +25424,22 @@ "type": "github" } }, + "nixpkgs_399": { + "locked": { + "lastModified": 1782841183, + "narHash": "sha256-Ndt/5R7UN4rBdhFR1lxHZZZ42cD6vGlnuxC2VvvsKE4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c9bfd86ed684d27e63b0ff9ebb18699f84f27a3b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11-small", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_4": { "locked": { "lastModified": 1759036355, @@ -26762,8 +26794,10 @@ }, "root": { "inputs": { + "crane": "crane", "logos-module-builder": "logos-module-builder", "logos_execution_zone": "logos_execution_zone", + "nixpkgs": "nixpkgs_399", "shared_wallet": "shared_wallet" } }, diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index f237473e..d01e26b8 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -3,6 +3,8 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11-small"; + crane.url = "github:ipetkov/crane/v0.23.4"; # Shared C++ wallet access and Logos.Wallet QML sources. shared_wallet = { @@ -28,14 +30,21 @@ }; }; - outputs = inputs@{ logos-module-builder, shared_wallet, ... }: - logos-module-builder.lib.mkLogosQmlModule { + outputs = inputs@{ logos-module-builder, shared_wallet, nixpkgs, crane, ... }: + let + walletDecoderInput = (import ../../flake.nix).outputs { + inherit nixpkgs crane; + }; + in logos-module-builder.lib.mkLogosQmlModule { src = ./.; configFile = ./metadata.json; flakeInputs = inputs; preConfigure = '' cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}") ''; + externalLibInputs = { + wallet_idl_decoder = walletDecoderInput; + }; postInstall = '' # The builder installs the view under lib/qml after this hook. Its # import descriptor points back to this compiled shared QML module. diff --git a/apps/amm/metadata.json b/apps/amm/metadata.json index 72508854..c670a3dc 100644 --- a/apps/amm/metadata.json +++ b/apps/amm/metadata.json @@ -14,7 +14,11 @@ "build": [], "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"] }, - "external_libraries": [], + "external_libraries": [ + { + "name": "wallet_idl_decoder" + } + ], "cmake": { "find_packages": [], "extra_sources": [], diff --git a/apps/amm/src/ActiveNetwork.cpp b/apps/amm/src/ActiveNetwork.cpp new file mode 100644 index 00000000..9bb1e275 --- /dev/null +++ b/apps/amm/src/ActiveNetwork.cpp @@ -0,0 +1,139 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include + +namespace { +const char NETWORK_ENV[] = "AMM_UI_NETWORK"; +const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE"; + +bool isLowerHex(const QString& value, int size) +{ + if (value.size() != size) + return false; + for (const QChar character : value) { + const bool digit = character >= QLatin1Char('0') + && character <= QLatin1Char('9'); + if (!digit && (character < QLatin1Char('a') || character > QLatin1Char('f'))) + return false; + } + return true; +} +} + +bool ActiveNetwork::load() +{ + m_network = {}; + m_network.status = QStringLiteral("config_missing"); + m_expectedIdentity.clear(); + const QByteArray selected = qgetenv(NETWORK_ENV); + m_network.id = selected.isEmpty() + ? QStringLiteral("testnet") + : QString::fromLocal8Bit(selected).trimmed(); + + QJsonObject entry; + if (isDevnet()) { + const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV)); + QFile file(path); + if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + entry = document.object(); + m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString(); + } else { + QFile file(QStringLiteral(":/amm/config/networks.json")); + if (!file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + entry = document.object().value(m_network.id).toObject(); + m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString(); + } + + m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString(); + if (!isValidIdentity(m_expectedIdentity) + || !isLowerHex(m_network.ammProgramId, 64)) { + return false; + } + for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) { + const QString id = value.toString(); + if (!isLowerHex(id, 64)) { + m_network.tokenIds.clear(); + return false; + } + m_network.tokenIds.append(id); + } + if (m_network.tokenIds.isEmpty()) + return false; + m_network.status = QStringLiteral("network_unknown"); + return true; +} + +bool ActiveNetwork::isConfigured() const +{ + return m_network.status != QStringLiteral("config_missing"); +} + +bool ActiveNetwork::isDevnet() const +{ + return m_network.id == QStringLiteral("devnet"); +} + +bool ActiveNetwork::needsIdentityProbe() const +{ + return m_network.status == QStringLiteral("loading") + || m_network.status == QStringLiteral("network_unknown"); +} + +void ActiveNetwork::sequencerChanged(bool available) +{ + if (isConfigured()) + clearIdentity(available ? QStringLiteral("loading") + : QStringLiteral("network_unknown")); +} + +void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable) +{ + if (!isConfigured()) + return; + if (!reachable) + clearIdentity(QStringLiteral("network_unknown")); + else if (!wasReachable) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::beginIdentityProbe() +{ + if (isConfigured()) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::finishIdentityProbe(const QString& identity) +{ + if (identity.isEmpty()) + clearIdentity(QStringLiteral("network_unknown")); + else if (identity != m_expectedIdentity) + clearIdentity(QStringLiteral("network_mismatch")); + else { + m_network.status = QStringLiteral("ready"); + m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:") + : QStringLiteral("block10:")) + + identity; + } +} + +bool ActiveNetwork::isValidIdentity(const QString& value) +{ + return isLowerHex(value, 64); +} + +void ActiveNetwork::clearIdentity(const QString& status) +{ + m_network.status = status; + m_network.fingerprint.clear(); +} diff --git a/apps/amm/src/ActiveNetwork.h b/apps/amm/src/ActiveNetwork.h new file mode 100644 index 00000000..9b1db9b7 --- /dev/null +++ b/apps/amm/src/ActiveNetwork.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +struct ActiveNetworkSnapshot { + QString id; + QString status; + QString fingerprint; + QString ammProgramId; + QStringList tokenIds; +}; + +class ActiveNetwork final { +public: + bool load(); + + const QString& status() const { return m_network.status; } + bool isConfigured() const; + bool isDevnet() const; + bool needsIdentityProbe() const; + ActiveNetworkSnapshot snapshot() const { return m_network; } + + void sequencerChanged(bool available); + void reachabilityChanged(bool reachable, bool wasReachable); + void beginIdentityProbe(); + void finishIdentityProbe(const QString& identity); + + static bool isValidIdentity(const QString& value); + +private: + void clearIdentity(const QString& status); + + ActiveNetworkSnapshot m_network; + QString m_expectedIdentity; +}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index fbe6a92c..106d68fe 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,18 +1,132 @@ #include "AmmUiBackend.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + #include "LogosWalletProvider.h" +#include "WalletAccountId.h" #include "WalletController.h" +#include "WalletIdlDecoder.h" #include "logos_api.h" +namespace { +constexpr int CHECKPOINT_BLOCK_ID = 10; +constexpr int BLOCK_HASH_OFFSET = 40; +constexpr int BLOCK_HASH_SIZE = 32; +const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0')); + +QByteArray resource(const QString& path) +{ + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); +} + +QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) +{ + return QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("method"), method }, + { QStringLiteral("params"), params }, + }).toJson(QJsonDocument::Compact); +} + +QString blockHashFromResponse(const QByteArray& payload) +{ + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(payload, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QByteArray block = QByteArray::fromBase64( + document.object().value(QStringLiteral("result")).toString().toLatin1()); + if (block.size() < BLOCK_HASH_OFFSET + BLOCK_HASH_SIZE) + return {}; + return QString::fromLatin1(block.mid(BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE).toHex()); +} + +QString channelIdFromResponse(const QByteArray& payload) +{ + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(payload, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QString channel = document.object().value(QStringLiteral("result")).toString(); + return ActiveNetwork::isValidIdentity(channel) ? channel : QString(); +} + +QString decimalAdd(const QString& left, const QString& right) +{ + if (left.isEmpty() || right.isEmpty()) + return {}; + if (!std::all_of(left.cbegin(), left.cend(), [](QChar value) { return value.isDigit(); }) + || !std::all_of(right.cbegin(), right.cend(), [](QChar value) { return value.isDigit(); })) { + return {}; + } + QString result; + result.reserve(std::max(left.size(), right.size()) + 1); + qsizetype leftIndex = left.size(); + qsizetype rightIndex = right.size(); + int carry = 0; + while (leftIndex > 0 || rightIndex > 0 || carry > 0) { + const int leftDigit = leftIndex > 0 + ? left.at(--leftIndex).digitValue() : 0; + const int rightDigit = rightIndex > 0 + ? right.at(--rightIndex).digitValue() : 0; + const int sum = leftDigit + rightDigit + carry; + result.prepend(QChar(QLatin1Char('0').unicode() + sum % 10)); + carry = sum / 10; + } + while (result.size() > 1 && result.startsWith(QLatin1Char('0'))) + result.remove(0, 1); + return result; +} + +QJsonObject enumFields(const QJsonValue& value, const QString& variant) +{ + return value.toObject().value(variant).toObject(); +} + +WalletAccountRead accountRead(const WalletAccount& account) +{ + WalletAccountRead read; + read.accountId = account.address; + read.status = account.readStatus; + read.programOwner = account.programOwner; + read.dataHex = account.dataHex; + return read; +} +} + AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), m_wallet(std::make_unique(m_logosAPI)), m_walletController(std::make_unique( - *m_wallet, QStringLiteral("AmmUI"))) + *m_wallet, QStringLiteral("AmmUI"))), + m_networkManager(new QNetworkAccessManager(this)), + m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), + m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) { + setAssets({}); + setAssetStatus(QStringLiteral("idle")); + setAssetError({}); + m_network.load(); + m_idlRegistry.registerProgram( + m_network.snapshot().ammProgramId, QStringLiteral("AMM"), m_ammIdl); + publishNetworkState(); connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); + connect(m_walletController.get(), &WalletController::snapshotChanged, + this, &AmmUiBackend::refreshPortfolio); syncWalletState(); m_walletController->start(); } @@ -71,10 +185,27 @@ void AmmUiBackend::disconnectWallet() m_walletController->disconnect(); } +bool AmmUiBackend::setAccountAlias(QString accountId, QString alias) +{ + return m_walletController->setAccountAlias(accountId, alias); +} + +bool AmmUiBackend::setPrimaryAccount(QString accountId) +{ + return m_walletController->setPrimaryAccount(accountId); +} + void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); + const QString previousAddress = sequencerAddr(); + const bool wasReachable = sequencerReachable(); setIsWalletOpen(state.isWalletOpen); + setWalletStateReady(state.syncStatus != QStringLiteral("opening") + && state.syncStatus != QStringLiteral("syncing")); + setWalletSyncStatus(state.syncStatus); + setWalletSyncError(state.syncError); + setWalletCanSubmit(state.canSubmit()); setWalletExists(state.walletExists); setConfigPath(state.configPath); setStoragePath(state.storagePath); @@ -83,4 +214,245 @@ void AmmUiBackend::syncWalletState() setCurrentBlockHeight(state.currentBlockHeight); setSequencerAddr(state.sequencerAddress); setSequencerReachable(state.sequencerReachable); + setPrimaryAccountAddress(state.primaryAccountAddress); + setPrimaryAccountName(state.primaryAccountName); + + const bool addressChanged = previousAddress != state.sequencerAddress; + if (addressChanged) + m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); + if (addressChanged || wasReachable != state.sequencerReachable) + m_network.reachabilityChanged(state.sequencerReachable, wasReachable); + publishNetworkState(); + if (state.sequencerReachable && m_network.needsIdentityProbe()) + probeNetworkIdentity(); +} + +void AmmUiBackend::publishNetworkState() +{ + const ActiveNetworkSnapshot network = m_network.snapshot(); + setActiveNetwork(network.id); + setNetworkStatus(network.status); + setNetworkFingerprint(network.fingerprint); +} + +void AmmUiBackend::probeNetworkIdentity() +{ + if (m_identityProbeInFlight || !m_network.isConfigured() || sequencerAddr().isEmpty()) + return; + m_identityProbeInFlight = true; + m_network.beginIdentityProbe(); + publishNetworkState(); + const QString address = sequencerAddr(); + const bool devnet = m_network.isDevnet(); + const QString method = devnet ? QStringLiteral("getChannelId") + : QStringLiteral("getBlock"); + const QJsonArray params = devnet ? QJsonArray() + : QJsonArray { CHECKPOINT_BLOCK_ID }; + QNetworkRequest request{QUrl(address)}; + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/json")); + request.setTransferTimeout(4000); + QNetworkReply* reply = m_networkManager->post(request, jsonRpcBody(method, params)); + connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { + m_identityProbeInFlight = false; + if (address != sequencerAddr()) { + reply->deleteLater(); + probeNetworkIdentity(); + return; + } + const QByteArray payload = reply->readAll(); + const QString identity = devnet ? channelIdFromResponse(payload) + : blockHashFromResponse(payload); + m_network.finishIdentityProbe(identity); + reply->deleteLater(); + publishNetworkState(); + refreshPortfolio(); + }); +} + +void AmmUiBackend::refreshPortfolio() +{ + const quint64 generation = ++m_portfolioGeneration; + if (!m_walletController->state().isWalletOpen) { + setAssets({}); + setAssetStatus(QStringLiteral("idle")); + setAssetError({}); + return; + } + if (m_network.status() != QStringLiteral("ready")) { + setAssets({}); + setAssetStatus(QStringLiteral("blocked")); + setAssetError(m_network.status()); + return; + } + if (m_tokenIdl.isEmpty()) { + setAssetStatus(QStringLiteral("error")); + setAssetError(QStringLiteral("token_idl_missing")); + return; + } + setAssetStatus(QStringLiteral("loading")); + setAssetError({}); + m_wallet->readPublicAccountsAsync( + m_network.snapshot().tokenIds, + [this, generation](QVector reads) { + applyDefinitions(generation, reads); + }); +} + +void AmmUiBackend::applyDefinitions( + quint64 generation, + const QVector& reads) +{ + if (generation != m_portfolioGeneration) + return; + const ActiveNetworkSnapshot network = m_network.snapshot(); + const WalletDecodeResult decoded = WalletIdlDecoder::decode(m_tokenIdl, reads); + if (!decoded.ok() || reads.size() != network.tokenIds.size() + || decoded.accounts.size() != reads.size()) { + setAssetStatus(QStringLiteral("error")); + setAssetError(decoded.error.isEmpty() + ? QStringLiteral("definition_decode_failed") + : decoded.error); + return; + } + + m_tokens.clear(); + m_tokenProgramId.clear(); + int unavailable = 0; + for (qsizetype index = 0; index < reads.size(); ++index) { + const WalletAccountRead& read = reads.at(index); + const WalletDecodedAccount& account = decoded.accounts.at(index); + TokenInfo token; + token.id = network.tokenIds.at(index); + token.name = QStringLiteral("Unknown token"); + token.status = QStringLiteral("unavailable"); + const QJsonObject fungible = enumFields(account.value, QStringLiteral("Fungible")); + if (read.ok() && account.status == QStringLiteral("decoded") + && account.typeName == QStringLiteral("TokenDefinition") + && !fungible.isEmpty() && read.programOwner != DEFAULT_PROGRAM_OWNER) { + token.name = fungible.value(QStringLiteral("name")).toString().trimmed(); + if (token.name.isEmpty()) + token.name = QStringLiteral("Unnamed token"); + token.programOwner = read.programOwner; + token.status = QStringLiteral("ready"); + if (m_tokenProgramId.isEmpty()) + m_tokenProgramId = read.programOwner; + else if (m_tokenProgramId != read.programOwner) { + setAssets({}); + setAssetStatus(QStringLiteral("error")); + setAssetError(QStringLiteral("token_program_mismatch")); + return; + } + } else { + ++unavailable; + } + m_tokens.append(std::move(token)); + } + if (m_tokenProgramId.isEmpty()) { + setAssets({}); + setAssetStatus(QStringLiteral("error")); + setAssetError(QStringLiteral("definitions_unavailable")); + return; + } + m_idlRegistry.registerProgram( + m_tokenProgramId, QStringLiteral("Token"), m_tokenIdl); + setAssetError(unavailable > 0 + ? QStringLiteral("some_definitions_unavailable") + : QString()); + applyWalletPortfolio(generation); +} + +void AmmUiBackend::applyWalletPortfolio(quint64 generation) +{ + if (generation != m_portfolioGeneration) + return; + const WalletSnapshot snapshot = m_walletController->snapshot(); + QVector programReads; + for (const WalletAccount& account : snapshot.accounts) { + if (!account.isPublic || account.readStatus != QStringLiteral("ok")) + continue; + programReads.append(accountRead(account)); + } + + QHash balances; + QVector presentations; + const QVector programs = m_idlRegistry.decode(programReads); + for (const WalletDecodedProgram& program : programs) { + for (const WalletDecodedAccount& account : program.result.accounts) { + WalletAccountPresentation presentation; + presentation.address = account.id; + presentation.programName = program.programName; + presentation.accountType = account.typeName; + if (program.programId == m_tokenProgramId + && account.typeName == QStringLiteral("TokenHolding")) { + const QJsonObject fungible = enumFields( + account.value, QStringLiteral("Fungible")); + if (fungible.isEmpty()) + continue; + const QString encodedId = fungible + .value(QStringLiteral("definition_id")).toString(); + const QString definitionId = account.accountIds.value(encodedId); + const QString amount = fungible.value(QStringLiteral("balance")).toString(); + const QString current = balances.value(definitionId, QStringLiteral("0")); + const QString total = decimalAdd(current, amount); + if (!definitionId.isEmpty() && !total.isEmpty()) + balances.insert(definitionId, total); + presentation.kind = QStringLiteral("token_holding"); + presentation.definitionId = definitionId; + presentation.hiddenFromAccounts = true; + for (const TokenInfo& token : m_tokens) { + if (token.id == definitionId) { + presentation.semanticName = token.name + QStringLiteral(" holding"); + break; + } + } + } else if (program.programId == m_tokenProgramId + && account.typeName == QStringLiteral("TokenDefinition")) { + presentation.kind = QStringLiteral("token_definition"); + const QJsonObject fungible = enumFields( + account.value, QStringLiteral("Fungible")); + presentation.semanticName = fungible.value(QStringLiteral("name")).toString(); + } else if (program.programId == m_tokenProgramId + && account.typeName == QStringLiteral("TokenMetadata")) { + presentation.kind = QStringLiteral("token_metadata"); + } else { + presentation.kind = QStringLiteral("program"); + presentation.semanticName = account.typeName; + } + presentations.append(std::move(presentation)); + } + } + m_walletController->applyAccountPresentations(presentations); + + QVariantList assets; + QVariantList available; + int unavailableCount = 0; + for (const TokenInfo& token : m_tokens) { + const QString balance = balances.value(token.id, QStringLiteral("0")); + const bool positive = balance != QStringLiteral("0"); + QString displayDefinitionId = walletAccountIdToBase58(token.id); + if (displayDefinitionId.isEmpty()) + displayDefinitionId = token.id; + QVariantMap asset { + { QStringLiteral("name"), token.name }, + { QStringLiteral("symbol"), token.name }, + { QStringLiteral("balance"), balance }, + { QStringLiteral("definitionId"), token.id }, + { QStringLiteral("displayDefinitionId"), displayDefinitionId }, + { QStringLiteral("programOwner"), token.programOwner }, + { QStringLiteral("status"), token.status }, + { QStringLiteral("section"), positive ? QStringLiteral("assets") + : QStringLiteral("available") }, + }; + if (positive) + assets.append(std::move(asset)); + else + available.append(std::move(asset)); + if (token.status != QStringLiteral("ready")) + ++unavailableCount; + } + assets.append(available); + setAssets(assets); + setAssetStatus(unavailableCount > 0 ? QStringLiteral("partial") + : QStringLiteral("ready")); } diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 99fa7e03..9b6d84d8 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -3,14 +3,19 @@ #include +#include #include +#include #include "rep_AmmUiBackend_source.h" +#include "ActiveNetwork.h" #include "WalletAccountModel.h" +#include "WalletIdlDecoder.h" class LogosAPI; class LogosWalletProvider; +class QNetworkAccessManager; class WalletController; class AmmUiBackend : public AmmUiBackendSimpleSource { @@ -33,13 +38,37 @@ public slots: QString createNew(QString configPath, QString storagePath, QString password) override; bool openExisting() override; void disconnectWallet() override; + bool setAccountAlias(QString accountId, QString alias) override; + bool setPrimaryAccount(QString accountId) override; private: + struct TokenInfo { + QString id; + QString name; + QString programOwner; + QString status; + }; + void syncWalletState(); + void publishNetworkState(); + void probeNetworkIdentity(); + void refreshPortfolio(); + void applyDefinitions(quint64 generation, + const QVector& reads); + void applyWalletPortfolio(quint64 generation); LogosAPI* m_logosAPI; std::unique_ptr m_wallet; std::unique_ptr m_walletController; + QNetworkAccessManager* m_networkManager; + ActiveNetwork m_network; + QByteArray m_tokenIdl; + QByteArray m_ammIdl; + WalletIdlRegistry m_idlRegistry; + QVector m_tokens; + QString m_tokenProgramId; + bool m_identityProbeInFlight = false; + quint64 m_portfolioGeneration = 0; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 6023dbb6..d6031ce0 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -5,6 +5,10 @@ class AmmUiBackend { PROP(bool isWalletOpen READONLY) + PROP(bool walletStateReady READONLY) + PROP(QString walletSyncStatus READONLY) + PROP(QString walletSyncError READONLY) + PROP(bool walletCanSubmit READONLY) PROP(bool walletExists READONLY) PROP(QString configPath READONLY) PROP(QString storagePath READONLY) @@ -15,6 +19,15 @@ class AmmUiBackend // Whether the configured sequencer answered the last reachability probe. // Defaults true so the UI doesn't flash a warning before the first check. PROP(bool sequencerReachable READONLY) + PROP(QString primaryAccountAddress READONLY) + PROP(QString primaryAccountName READONLY) + + PROP(QString activeNetwork READONLY) + PROP(QString networkStatus READONLY) + PROP(QString networkFingerprint READONLY) + PROP(QVariantList assets READONLY) + PROP(QString assetStatus READONLY) + PROP(QString assetError READONLY) // Account management SLOT(QString createAccountPublic()) @@ -22,6 +35,8 @@ class AmmUiBackend SLOT(void refreshAccounts()) SLOT(void refreshBalances()) SLOT(QString getBalance(QString accountIdHex, bool isPublic)) + SLOT(bool setAccountAlias(QString accountId, QString alias)) + SLOT(bool setPrimaryAccount(QString accountId)) // Wallet lifecycle. createNewDefault() is the happy path: it creates a // fresh per-app wallet at walletHome with no path picking. createNew() diff --git a/apps/amm/src/WalletIdlDecoder.cpp b/apps/amm/src/WalletIdlDecoder.cpp new file mode 100644 index 00000000..986046de --- /dev/null +++ b/apps/amm/src/WalletIdlDecoder.cpp @@ -0,0 +1,100 @@ +#include "WalletIdlDecoder.h" + +#include + +#include +#include +#include +#include + +#include + +WalletDecodeResult WalletIdlDecoder::decode( + const QByteArray& idlJson, + const QVector& accounts) +{ + WalletDecodeResult result; + QJsonParseError idlError; + const QJsonDocument idl = QJsonDocument::fromJson(idlJson, &idlError); + if (idlError.error != QJsonParseError::NoError || !idl.isObject()) { + result.status = QStringLiteral("error"); + result.error = QStringLiteral("invalid_idl"); + return result; + } + + QJsonArray inputs; + for (const WalletAccountRead& account : accounts) { + inputs.append(QJsonObject { + { QStringLiteral("id"), account.accountId }, + { QStringLiteral("dataHex"), account.dataHex }, + }); + } + const QByteArray request = QJsonDocument(QJsonObject { + { QStringLiteral("idl"), idl.object() }, + { QStringLiteral("accounts"), inputs }, + }).toJson(QJsonDocument::Compact); + + char* responsePointer = wallet_idl_decode_accounts(request.constData()); + if (!responsePointer) { + result.status = QStringLiteral("error"); + result.error = QStringLiteral("decoder_unavailable"); + return result; + } + const QByteArray response(responsePointer); + wallet_idl_decoder_free(responsePointer); + + QJsonParseError responseError; + const QJsonDocument document = QJsonDocument::fromJson(response, &responseError); + if (responseError.error != QJsonParseError::NoError || !document.isObject()) { + result.status = QStringLiteral("error"); + result.error = QStringLiteral("invalid_decoder_response"); + return result; + } + + const QJsonObject root = document.object(); + result.status = root.value(QStringLiteral("status")).toString(); + result.error = root.value(QStringLiteral("error")).toString(); + for (const QJsonValue& value : root.value(QStringLiteral("accounts")).toArray()) { + const QJsonObject decoded = value.toObject(); + WalletDecodedAccount account; + account.id = decoded.value(QStringLiteral("id")).toString(); + account.status = decoded.value(QStringLiteral("status")).toString(); + account.typeName = decoded.value(QStringLiteral("typeName")).toString(); + account.value = decoded.value(QStringLiteral("value")); + const QJsonObject ids = decoded.value(QStringLiteral("accountIds")).toObject(); + for (auto iterator = ids.begin(); iterator != ids.end(); ++iterator) + account.accountIds.insert(iterator.key(), iterator.value().toString()); + result.accounts.append(std::move(account)); + } + return result; +} + +void WalletIdlRegistry::registerProgram(const QString& programId, + const QString& programName, + const QByteArray& idlJson) +{ + if (!programId.isEmpty() && !programName.isEmpty() && !idlJson.isEmpty()) + m_programs.insert(programId, { programName, idlJson }); +} + +QVector WalletIdlRegistry::decode( + const QVector& accounts) const +{ + QHash> grouped; + for (const WalletAccountRead& account : accounts) { + if (m_programs.contains(account.programOwner)) + grouped[account.programOwner].append(account); + } + + QVector decoded; + decoded.reserve(grouped.size()); + for (auto iterator = grouped.cbegin(); iterator != grouped.cend(); ++iterator) { + const Program program = m_programs.value(iterator.key()); + decoded.append({ + iterator.key(), + program.name, + WalletIdlDecoder::decode(program.idl, iterator.value()), + }); + } + return decoded; +} diff --git a/apps/amm/src/WalletIdlDecoder.h b/apps/amm/src/WalletIdlDecoder.h new file mode 100644 index 00000000..08112266 --- /dev/null +++ b/apps/amm/src/WalletIdlDecoder.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "WalletProvider.h" + +struct WalletDecodedAccount { + QString id; + QString status; + QString typeName; + QJsonValue value; + QHash accountIds; +}; + +struct WalletDecodeResult { + QString status; + QString error; + QVector accounts; + + bool ok() const { return status == QStringLiteral("ok"); } +}; + +class WalletIdlDecoder final { +public: + static WalletDecodeResult decode(const QByteArray& idlJson, + const QVector& accounts); +}; + +struct WalletDecodedProgram { + QString programId; + QString programName; + WalletDecodeResult result; +}; + +class WalletIdlRegistry final { +public: + void registerProgram(const QString& programId, + const QString& programName, + const QByteArray& idlJson); + QVector decode( + const QVector& accounts) const; + +private: + struct Program { + QString name; + QByteArray idl; + }; + + QHash m_programs; +}; diff --git a/apps/amm/tests/cpp/ActiveNetworkTest.cpp b/apps/amm/tests/cpp/ActiveNetworkTest.cpp new file mode 100644 index 00000000..0f5250ca --- /dev/null +++ b/apps/amm/tests/cpp/ActiveNetworkTest.cpp @@ -0,0 +1,47 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include +#include + +class ActiveNetworkTest : public QObject { + Q_OBJECT + +private slots: + void validatesIdentityBeforeReadiness(); +}; + +void ActiveNetworkTest::validatesIdentityBeforeReadiness() +{ + const QString identity(64, QLatin1Char('a')); + const QString programId(64, QLatin1Char('b')); + const QString tokenId(64, QLatin1Char('c')); + QTemporaryFile config; + QVERIFY(config.open()); + config.write(QJsonDocument(QJsonObject { + { QStringLiteral("channelId"), identity }, + { QStringLiteral("ammProgramId"), programId }, + { QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } }, + }).toJson(QJsonDocument::Compact)); + config.flush(); + qputenv("AMM_UI_NETWORK", "devnet"); + qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit()); + + ActiveNetwork network; + QVERIFY(network.load()); + QCOMPARE(network.status(), QStringLiteral("network_unknown")); + network.sequencerChanged(true); + network.finishIdentityProbe(QString(64, QLatin1Char('d'))); + QCOMPARE(network.status(), QStringLiteral("network_mismatch")); + network.reachabilityChanged(false, true); + network.reachabilityChanged(true, false); + network.finishIdentityProbe(identity); + QCOMPARE(network.status(), QStringLiteral("ready")); + QCOMPARE(network.snapshot().fingerprint, QStringLiteral("channel:") + identity); + QCOMPARE(network.snapshot().tokenIds, QStringList { tokenId }); +} + +QTEST_GUILESS_MAIN(ActiveNetworkTest) +#include "ActiveNetworkTest.moc" diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 896716b8..32adf6a4 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -29,6 +29,8 @@ if(LOGOS_WALLET_BUILD_ACCESS) src/WalletProvider.cpp src/LogosWalletProvider.h src/LogosWalletProvider.cpp + src/WalletAccountId.h + src/WalletAccountId.cpp src/WalletAccountModel.h src/WalletAccountModel.cpp src/WalletController.h @@ -140,6 +142,8 @@ if(BUILD_TESTING) tests/cpp/LogosWalletProviderTest.cpp src/WalletProvider.cpp src/LogosWalletProvider.cpp + src/WalletAccountId.cpp + src/WalletAccountId.h src/WalletAccountModel.cpp src/WalletAccountModel.h src/WalletController.cpp diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml index 28d32a13..0136de7c 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -12,15 +12,29 @@ Item { property var watchCall: null property bool compact: false property real viewportWidth: width - property int selectedIndex: 0 + property int selectedIndex: -1 property bool busy: false + property bool openPending: false + property bool advancedExpanded: false + property bool availableExpanded: false + property string postCreationWarning: "" + property bool createdWalletAwaitingAcknowledgement: false + property string reportedOpenErrorKey: "" readonly property bool connected: root.wallet !== null && root.wallet.isWalletOpen readonly property bool compactLayout: root.compact || root.viewportWidth < 680 + readonly property bool walletOpening: root.wallet !== null + && (root.wallet.walletSyncStatus === "opening" + || root.wallet.walletSyncStatus === "syncing") readonly property string selectedAddress: root.accountAt(root.selectedIndex, "address") + readonly property string selectedDisplayAddress: root.accountAt(root.selectedIndex, "displayAddress") readonly property string selectedName: root.accountAt(root.selectedIndex, "name") readonly property string selectedBalance: root.accountAt(root.selectedIndex, "balance") readonly property bool selectedIsPublic: root.accountAt(root.selectedIndex, "isPublic") === true + readonly property var walletAssets: root.wallet && root.wallet.assets ? root.wallet.assets : [] + readonly property int availableAssetCount: root.assetCount("available") + readonly property string primaryName: root.wallet && root.wallet.primaryAccountName + ? root.wallet.primaryAccountName : root.selectedName implicitWidth: root.connected ? connectedButton.implicitWidth : connectButton.implicitWidth implicitHeight: 40 @@ -30,11 +44,15 @@ Item { model: root.accountModel delegate: QtObject { required property string address + required property string displayAddress required property string name required property string balance required property bool isPublic + required property bool isPrimary + required property bool canBePrimary + required property string kind } - onCountChanged: root.clampSelection() + onCountChanged: root.syncPrimarySelection() } function accountAt(index, field) { @@ -42,26 +60,50 @@ Item { return entry ? entry[field] : (field === "isPublic" ? false : "") } - function clampSelection() { + function assetCount(sectionName) { + let count = 0 + for (const asset of root.walletAssets) { + if (asset.section === sectionName) + ++count + } + return count + } + + function syncPrimarySelection() { if (accounts.count === 0) { - root.selectedIndex = 0 - } else { - root.selectedIndex = Math.max(0, Math.min(root.selectedIndex, accounts.count - 1)) + root.selectedIndex = -1 + return + } + const requested = root.wallet && root.wallet.primaryAccountAddress + ? root.wallet.primaryAccountAddress : "" + for (let index = 0; index < accounts.count; ++index) { + const account = accounts.objectAt(index) + if ((requested.length > 0 && account.address === requested) || account.isPrimary) { + root.selectedIndex = index + return + } + } + for (let index = 0; index < accounts.count; ++index) { + const account = accounts.objectAt(index) + if (account.kind === "user" && account.canBePrimary) { + root.selectedIndex = index + return + } } + root.selectedIndex = -1 } function shortAddress(address) { return address && address.length > 13 - ? address.substring(0, 6) + "..." + address.substring(address.length - 4) + ? address.substring(0, 6) + "…" + address.substring(address.length - 4) : address || "" } function watchResult(result, success, failure) { - if (root.watchCall) { + if (root.watchCall) root.watchCall(result, success, failure) - } else { + else success(result) - } } function showError(message) { @@ -69,22 +111,108 @@ Item { messageDialog.open() } + function walletRefreshWarning(subject) { + if (!root.wallet || root.wallet.walletSyncStatus !== "error") + return "" + return qsTr("%1 was created, but could not be refreshed. Reconnect the wallet to refresh it.") + .arg(subject) + } + + function openFailureKey() { + if (!root.wallet || root.wallet.walletSyncStatus !== "error") + return "" + return root.wallet.walletSyncError || "unknown" + } + + function openFailureMessage() { + const error = root.wallet ? root.wallet.walletSyncError : "" + return error + ? qsTr("Wallet could not be opened: %1").arg(error) + : qsTr("Wallet could not be opened.") + } + + function reportUnhandledOpenFailure() { + if (!root.wallet || root.openPending || root.wallet.isWalletOpen + || root.wallet.walletSyncStatus !== "error") { + return + } + const key = root.openFailureKey() + if (key === root.reportedOpenErrorKey) + return + root.reportedOpenErrorKey = key + root.showError(root.openFailureMessage()) + } + + function openAccepted(result) { + return result === true || result === "true" || result === 1 || result === "1" + } + + function finishOpen() { + root.openPending = false + root.busy = false + } + + function failOpen(message) { + if (!root.openPending) + return + root.reportedOpenErrorKey = root.openFailureKey() + root.finishOpen() + root.showError(message) + } + + function settleOpenFromWalletState() { + if (!root.openPending || !root.wallet) + return + const status = root.wallet.walletSyncStatus + if (status === undefined) { + root.finishOpen() + return + } + if (status === "ready" && root.wallet.isWalletOpen) { + root.finishOpen() + return + } + if (status === "error") { + root.failOpen(root.openFailureMessage()) + return + } + if (status === "closed" && root.wallet.walletExists === false) + root.failOpen(qsTr("Wallet could not be opened.")) + } + function openWallet() { - if (!root.wallet || root.busy) + if (!root.wallet || root.busy || root.walletOpening) return root.busy = true + root.openPending = true try { root.watchResult(root.wallet.openExisting(), function(ok) { - root.busy = false + if (!root.openAccepted(ok)) { + root.failOpen(qsTr("Wallet could not be opened.")) + return + } + Qt.callLater(root.settleOpenFromWalletState) + }, function(error) { + root.failOpen(qsTr("Wallet could not be opened: %1").arg(error)) + }) + } catch (error) { + root.failOpen(qsTr("Wallet could not be opened: %1").arg(error)) + } + } + + function makePrimary(address) { + if (!root.wallet || !address) + return + try { + root.watchResult(root.wallet.setPrimaryAccount(address), function(ok) { if (!ok) - root.showError(qsTr("Wallet could not be opened.")) + root.showError(qsTr("This account cannot be primary.")) + root.syncPrimarySelection() }, function(error) { - root.busy = false - root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + root.showError(qsTr("Primary account could not be changed: %1").arg(error)) }) } catch (error) { - root.busy = false - root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + root.showError(qsTr("Primary account could not be changed: %1").arg(error)) } } @@ -106,18 +234,40 @@ Item { Connections { target: root.accountModel ignoreUnknownSignals: true - function onModelReset() { root.clampSelection() } - function onRowsInserted() { root.clampSelection() } - function onRowsRemoved() { root.clampSelection() } + function onModelReset() { root.syncPrimarySelection() } + function onRowsInserted() { root.syncPrimarySelection() } + function onRowsRemoved() { root.syncPrimarySelection() } + function onDataChanged() { root.syncPrimarySelection() } } + Connections { + target: root.wallet + ignoreUnknownSignals: true + function onPrimaryAccountAddressChanged() { root.syncPrimarySelection() } + function onWalletSyncStatusChanged() { + if (!root.wallet || root.wallet.walletSyncStatus !== "error") + root.reportedOpenErrorKey = "" + Qt.callLater(root.settleOpenFromWalletState) + Qt.callLater(root.reportUnhandledOpenFailure) + } + function onWalletSyncErrorChanged() { + Qt.callLater(root.settleOpenFromWalletState) + Qt.callLater(root.reportUnhandledOpenFailure) + } + function onIsWalletOpenChanged() { Qt.callLater(root.settleOpenFromWalletState) } + function onWalletExistsChanged() { Qt.callLater(root.settleOpenFromWalletState) } + } + + Component.onCompleted: Qt.callLater(root.reportUnhandledOpenFailure) + onConnectedChanged: { if (!root.connected) { - root.selectedIndex = 0 + root.selectedIndex = -1 walletMenu.close() + } else { + root.syncPrimarySelection() } } - onViewportWidthChanged: { if (walletMenu.opened) Qt.callLater(walletMenu.updateAnchor) @@ -129,24 +279,20 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: !root.connected - enabled: root.wallet !== null && !root.busy + enabled: root.wallet !== null && !root.busy && !root.walletOpening implicitHeight: 40 implicitWidth: root.compactLayout ? 40 : 108 - text: root.compactLayout ? "" : root.busy ? qsTr("Connecting...") : qsTr("Connect") - display: root.compactLayout ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + text: root.compactLayout ? "" : (root.busy || root.walletOpening + ? qsTr("Connecting…") : qsTr("Connect")) icon.source: Qt.resolvedUrl("icons/account.svg") - icon.color: "#ffffff" - icon.width: 18 - icon.height: 18 - Accessible.name: qsTr("Connect wallet") + Accessible.name: qsTr("Connect AMM Wallet") ToolTip.text: Accessible.name ToolTip.visible: hovered && root.compactLayout background: Rectangle { - color: connectButton.pressed ? "#d95c1e" : "#f26a21" - radius: 6 + color: connectButton.pressed ? "#d97706" : "#f59e0b" + radius: 8 } - contentItem: RowLayout { spacing: 6 Image { @@ -159,12 +305,11 @@ Item { Layout.fillWidth: true visible: !root.compactLayout text: connectButton.text - color: "#ffffff" + color: "#18181b" font.bold: true horizontalAlignment: Text.AlignHCenter } } - onClicked: { if (root.wallet && root.wallet.walletExists) root.openWallet() @@ -181,42 +326,43 @@ Item { visible: root.connected enabled: !root.busy implicitHeight: 40 - implicitWidth: root.compactLayout ? 44 : Math.max(140, accountButtonLabel.implicitWidth + 58) - Accessible.name: qsTr("Wallet account %1").arg(root.selectedAddress) + implicitWidth: root.compactLayout ? 44 : Math.max(176, accountButtonLabel.implicitWidth + 54) + Accessible.name: qsTr("AMM Wallet, primary account %1").arg(root.primaryName) background: Rectangle { color: connectedButton.pressed ? "#3f3f46" : "#27272a" border.width: walletMenu.opened || connectedButton.activeFocus ? 1 : 0 - border.color: "#f26a21" - radius: 6 + border.color: "#f59e0b" + radius: 8 } - contentItem: RowLayout { spacing: 8 - Rectangle { Layout.preferredWidth: 8 Layout.preferredHeight: 8 radius: 4 - color: "#22c55e" + color: !root.wallet || root.wallet.networkStatus === undefined + || root.wallet.networkStatus === "ready" + ? "#22c55e" + : root.wallet.networkStatus === "loading" ? "#f59e0b" : "#ef4444" } - Label { id: accountButtonLabel Layout.fillWidth: true visible: !root.compactLayout - text: root.shortAddress(root.selectedAddress) || qsTr("Connected") - color: "#f4f4f5" - horizontalAlignment: Text.AlignHCenter + text: root.primaryName.length > 0 + ? qsTr("AMM Wallet · %1").arg(root.primaryName) + : qsTr("AMM Wallet") + color: "#fafafa" + font.bold: true + elide: Text.ElideRight } - Label { visible: !root.compactLayout - text: walletMenu.opened ? "\u25b4" : "\u25be" + text: walletMenu.opened ? "▴" : "▾" color: "#a1a1aa" } } - onClicked: { if (walletMenu.opened || Date.now() - walletMenu.lastClosedMs < 200) walletMenu.close() @@ -243,10 +389,8 @@ Item { Math.min(connectedButton.width - width, viewport.width - width - 12 - anchorPosition.x)) : connectedButton.width - width - y: opensAbove - ? -height - 8 - : connectedButton.height + 8 - width: Math.min(360, Math.max(0, Math.min(root.viewportWidth, + y: opensAbove ? -height - 8 : connectedButton.height + 8 + width: Math.min(400, Math.max(0, Math.min(root.viewportWidth, viewport ? viewport.width : root.viewportWidth) - 24)) height: Math.min(implicitHeight, availableMenuHeight) margins: 12 @@ -257,31 +401,28 @@ Item { if (viewport) anchorPosition = connectedButton.mapToItem(viewport, 0, 0) } - onAboutToShow: updateAnchor() - onClosed: { walletMenu.lastClosedMs = Date.now() if (walletStack.depth > 1) walletStack.pop(null, StackView.Immediate) } - Connections { target: walletMenu.opened ? walletMenu.viewport : null - function onWidthChanged() { Qt.callLater(walletMenu.updateAnchor) } function onHeightChanged() { Qt.callLater(walletMenu.updateAnchor) } } - background: Rectangle { color: "#18181b" border.color: "#3f3f46" border.width: 1 - radius: 8 + radius: 10 } contentItem: StackView { id: walletStack + objectName: "walletStack" + clip: true width: walletMenu.availableWidth height: walletMenu.availableHeight implicitWidth: walletMenu.availableWidth @@ -291,86 +432,214 @@ Item { Component { id: walletOverview - - ColumnLayout { - spacing: 12 - - RowLayout { - Layout.fillWidth: true - - Item { Layout.fillWidth: true } - - WalletIconButton { - objectName: "walletAccountsButton" - iconSource: Qt.resolvedUrl("icons/account.svg") - accessibleName: qsTr("Accounts") - onClicked: walletStack.push(accountList) - } - - WalletIconButton { - objectName: "walletDisconnectButton" - iconSource: Qt.resolvedUrl("icons/power.svg") - accessibleName: qsTr("Disconnect") - onClicked: { - walletMenu.close() - if (root.wallet) - root.wallet.disconnectWallet() - } - } - } - - Rectangle { - Layout.fillWidth: true - implicitHeight: accountCard.implicitHeight + 24 - color: "#27272a" - radius: 6 - - ColumnLayout { - id: accountCard - anchors.fill: parent - anchors.margins: 12 - spacing: 8 - - RowLayout { + ScrollView { + implicitHeight: Math.min(overviewContent.implicitHeight, 520) + contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ColumnLayout { + id: overviewContent + objectName: "walletOverviewContent" + width: parent.width + spacing: 12 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { Layout.fillWidth: true - + spacing: 1 Label { - text: root.selectedName || qsTr("Account") - color: "#f4f4f5" + text: qsTr("AMM Wallet") + color: "#fafafa" font.bold: true + font.pixelSize: 16 } - Label { - text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") + text: root.wallet && root.wallet.activeNetwork + ? root.wallet.activeNetwork : qsTr("Network unavailable") color: "#a1a1aa" font.pixelSize: 11 } - - Item { Layout.fillWidth: true } - - Label { - text: root.selectedBalance || "-" - color: "#f4f4f5" - font.bold: true + } + WalletIconButton { + objectName: "walletAccountsButton" + iconSource: Qt.resolvedUrl("icons/account.svg") + accessibleName: qsTr("Accounts") + onClicked: walletStack.push(accountList, StackView.Immediate) + } + WalletIconButton { + objectName: "walletDisconnectButton" + iconSource: Qt.resolvedUrl("icons/power.svg") + accessibleName: qsTr("Disconnect") + onClicked: { + walletMenu.close() + if (root.wallet) + root.wallet.disconnectWallet() } } + } - RowLayout { - Layout.fillWidth: true - spacing: 4 - - Label { + Rectangle { + Layout.fillWidth: true + implicitHeight: identityCard.implicitHeight + 24 + color: "#27272a" + radius: 8 + ColumnLayout { + id: identityCard + anchors.fill: parent + anchors.margins: 12 + spacing: 7 + RowLayout { Layout.fillWidth: true - text: root.selectedAddress + Label { + Layout.fillWidth: true + text: root.primaryName || qsTr("No primary account") + color: "#fafafa" + font.bold: true + elide: Text.ElideRight + } + Label { + text: qsTr("Primary") + color: "#fbbf24" + font.pixelSize: 11 + font.bold: true + } + } + Label { + text: root.selectedIsPublic ? qsTr("Public user account") + : qsTr("Private account") color: "#a1a1aa" - font.family: "monospace" font.pixelSize: 11 - elide: Text.ElideMiddle } + RowLayout { + Layout.fillWidth: true + spacing: 4 + Label { + Layout.fillWidth: true + text: root.selectedDisplayAddress + color: "#71717a" + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + CopyButton { + visible: root.selectedDisplayAddress.length > 0 + onCopyRequested: root.copyToClipboard(root.selectedDisplayAddress) + } + } + } + } - CopyButton { - visible: root.selectedAddress.length > 0 - onCopyRequested: root.copyToClipboard(root.selectedAddress) + Label { + text: qsTr("Assets") + color: "#fafafa" + font.bold: true + } + Label { + visible: root.wallet && root.wallet.assetStatus === "loading" + text: qsTr("Loading balances…") + color: "#a1a1aa" + } + Repeater { + model: root.walletAssets + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + visible: modelData.section === "assets" + implicitHeight: visible ? 62 : 0 + color: "#27272a" + radius: 8 + RowLayout { + anchors.fill: parent + anchors.margins: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: modelData.name + color: "#fafafa" + font.bold: true + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: root.shortAddress(modelData.displayDefinitionId + || modelData.definitionId) + color: "#71717a" + font.family: "monospace" + font.pixelSize: 10 + } + } + Label { + text: modelData.balance + color: "#fafafa" + font.family: "monospace" + font.bold: true + } + CopyButton { + onCopyRequested: root.copyToClipboard( + modelData.displayDefinitionId || modelData.definitionId) + } + } + } + } + Label { + visible: (!root.walletAssets || root.walletAssets.length === 0) + && (!root.wallet || root.wallet.assetStatus !== "loading") + text: root.wallet && root.wallet.assetError + ? qsTr("Assets unavailable: %1").arg(root.wallet.assetError) + : qsTr("No assets yet") + color: "#a1a1aa" + wrapMode: Text.Wrap + } + Button { + objectName: "walletAvailableAssetsButton" + Layout.fillWidth: true + visible: root.availableAssetCount > 0 + text: root.availableExpanded ? qsTr("Hide available tokens") + : qsTr("Available tokens") + flat: true + onClicked: root.availableExpanded = !root.availableExpanded + } + Repeater { + model: root.walletAssets + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + visible: root.availableExpanded && modelData.section === "available" + implicitHeight: visible ? 58 : 0 + color: "#202023" + radius: 8 + RowLayout { + anchors.fill: parent + anchors.margins: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: modelData.name + color: modelData.status === "ready" ? "#d4d4d8" : "#a1a1aa" + elide: Text.ElideRight + } + Label { + text: root.shortAddress(modelData.displayDefinitionId + || modelData.definitionId) + color: "#71717a" + font.family: "monospace" + font.pixelSize: 10 + } + } + Label { + text: modelData.status === "ready" ? "0" : qsTr("Unavailable") + color: "#71717a" + font.pixelSize: 11 + } + CopyButton { + onCopyRequested: root.copyToClipboard( + modelData.displayDefinitionId || modelData.definitionId) + } } } } @@ -380,77 +649,173 @@ Item { Component { id: accountList - ColumnLayout { - spacing: 12 - + spacing: 10 RowLayout { Layout.fillWidth: true - WalletIconButton { + objectName: "walletAccountsBackButton" iconSource: Qt.resolvedUrl("icons/back.svg") accessibleName: qsTr("Back") - onClicked: walletStack.pop() + onClicked: walletStack.pop(null, StackView.Immediate) } - Label { Layout.fillWidth: true text: qsTr("Accounts") - color: "#f4f4f5" + color: "#fafafa" font.bold: true } } - + Label { + Layout.fillWidth: true + visible: walletMenu.availableMenuHeight >= 280 + text: qsTr("Choose the account used as your wallet identity. Program records stay under Advanced.") + color: "#a1a1aa" + font.pixelSize: 11 + wrapMode: Text.Wrap + } ListView { id: accountListView objectName: "walletAccountList" Layout.fillWidth: true Layout.fillHeight: true - Layout.minimumHeight: 0 - Layout.preferredHeight: Math.min(contentHeight, 260) + Layout.minimumHeight: 48 + Layout.preferredHeight: Math.min(contentHeight, 300) clip: true - spacing: 6 + spacing: 0 model: root.accountModel ScrollIndicator.vertical: ScrollIndicator { } - - delegate: AccountDelegate { + delegate: Item { + id: accountWrapper + required property int index + required property string name + required property string alias + required property string address + required property string displayAddress + required property string balance + required property bool isPublic + required property string kind + required property string section + required property string programName + required property string accountType + required property string visibility + required property bool canBePrimary + required property bool isPrimary + + readonly property bool shown: section === "accounts" + || (root.advancedExpanded && section === "advanced") width: ListView.view.width - highlighted: index === root.selectedIndex - onClicked: { - root.selectedIndex = index - walletStack.pop() + height: shown ? accountDelegate.implicitHeight + 6 : 0 + visible: shown + + function clicked() { + if (canBePrimary && !isPrimary) + root.makePrimary(address) + } + + AccountDelegate { + id: accountDelegate + width: parent.width + index: accountWrapper.index + name: accountWrapper.name + alias: accountWrapper.alias + address: accountWrapper.address + displayAddress: accountWrapper.displayAddress + balance: accountWrapper.balance + isPublic: accountWrapper.isPublic + kind: accountWrapper.kind + section: accountWrapper.section + programName: accountWrapper.programName + accountType: accountWrapper.accountType + visibility: accountWrapper.visibility + canBePrimary: accountWrapper.canBePrimary + isPrimary: accountWrapper.isPrimary + onMakePrimaryRequested: function(address) { root.makePrimary(address) } + onRenameRequested: function(address, alias) { + renameDialog.accountAddress = address + renameField.text = alias + renameDialog.open() + } + onCopyRequested: function(text) { root.copyToClipboard(text) } } - onCopyRequested: function(text) { root.copyToClipboard(text) } } } - - Button { - objectName: "walletAddAccountButton" + RowLayout { Layout.fillWidth: true - text: qsTr("Add account") - enabled: !root.busy - onClicked: createAccountDialog.open() + spacing: 6 + Button { + objectName: "walletAdvancedAccountsButton" + Layout.fillWidth: true + text: root.advancedExpanded ? qsTr("Hide Advanced") : qsTr("Advanced") + flat: true + onClicked: root.advancedExpanded = !root.advancedExpanded + } + Button { + objectName: "walletAddAccountButton" + Layout.fillWidth: true + text: qsTr("Add account") + enabled: !root.busy + onClicked: createAccountDialog.open() + } } } } } + Dialog { + id: renameDialog + objectName: "walletRenameDialog" + property string accountAddress: "" + parent: Overlay.overlay + modal: true + anchors.centerIn: parent + width: Math.min(360, parent ? parent.width - 32 : 360) + title: qsTr("Rename account") + standardButtons: Dialog.Save | Dialog.Cancel + TextField { + id: renameField + objectName: "walletAliasField" + width: parent.width + maximumLength: 40 + placeholderText: qsTr("Account name") + Accessible.name: qsTr("Account name") + } + onAccepted: { + if (!root.wallet) + return + try { + root.watchResult(root.wallet.setAccountAlias(accountAddress, renameField.text), + function(ok) { + if (!ok) + root.showError(qsTr("Account name could not be saved.")) + }, function(error) { + root.showError(qsTr("Account name could not be saved: %1").arg(error)) + }) + } catch (error) { + root.showError(qsTr("Account name could not be saved: %1").arg(error)) + } + } + } + CreateWalletDialog { id: createWalletDialog objectName: "createWalletDialog" walletHome: root.wallet ? root.wallet.walletHome || "" : "" busy: root.busy - onCreateRequested: function(password) { if (!root.wallet || root.busy) return + root.postCreationWarning = "" + root.createdWalletAwaitingAcknowledgement = false root.busy = true try { root.watchResult(root.wallet.createNewDefault(password), function(mnemonic) { root.busy = false - if (mnemonic && mnemonic.length > 0) + if (mnemonic && mnemonic.length > 0) { createWalletDialog.mnemonic = mnemonic - else + root.createdWalletAwaitingAcknowledgement = true + root.postCreationWarning = root.walletRefreshWarning(qsTr("Wallet")) + } else createWalletDialog.errorText = qsTr("Wallet could not be created.") }, function(error) { root.busy = false @@ -461,30 +826,40 @@ Item { createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error) } } - onCopyRequested: function(text) { root.copyToClipboard(text) } + onClosed: { + const warning = root.postCreationWarning.length > 0 ? root.postCreationWarning + : root.createdWalletAwaitingAcknowledgement + ? root.walletRefreshWarning(qsTr("Wallet")) : "" + root.postCreationWarning = "" + root.createdWalletAwaitingAcknowledgement = false + if (warning.length > 0) + root.showError(warning) + } } CreateAccountDialog { id: createAccountDialog objectName: "createAccountDialog" busy: root.busy - onCreateRequested: function(isPublic) { if (!root.wallet || root.busy) return root.busy = true try { - const request = isPublic - ? root.wallet.createAccountPublic() - : root.wallet.createAccountPrivate() + const request = isPublic ? root.wallet.createAccountPublic() + : root.wallet.createAccountPrivate() root.watchResult(request, function(accountId) { root.busy = false if (accountId && accountId.length > 0) { createAccountDialog.close() - } else { + Qt.callLater(function() { + const warning = root.walletRefreshWarning(qsTr("Account")) + if (warning.length > 0) + root.showError(warning) + }) + } else root.showError(qsTr("Account could not be created.")) - } }, function(error) { root.busy = false root.showError(qsTr("Account could not be created: %1").arg(error)) diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml index b0ff529f..b0918fc1 100644 --- a/apps/shared/wallet/qml/internal/AccountDelegate.qml +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -7,71 +7,136 @@ ItemDelegate { required property int index required property string name + required property string alias required property string address + required property string displayAddress required property string balance required property bool isPublic + required property string kind + required property string section + required property string programName + required property string accountType + required property string visibility + required property bool canBePrimary + required property bool isPrimary signal copyRequested(string text) + signal makePrimaryRequested(string address) + signal renameRequested(string address, string alias) leftPadding: 12 rightPadding: 8 topPadding: 10 bottomPadding: 10 + enabled: root.section !== "hidden" + Accessible.name: root.isPrimary + ? qsTr("%1, primary account").arg(root.name) + : root.name - Accessible.name: qsTr("%1, balance %2").arg(root.name).arg(root.balance || "0") + function kindLabel() { + if (root.kind === "user") + return qsTr("User") + if (root.kind === "private") + return qsTr("Account") + if (root.accountType.length > 0) + return root.accountType + return root.kind === "unknown" ? qsTr("Unknown") : qsTr("Program") + } background: Rectangle { - color: root.highlighted || root.hovered ? "#27272a" : "#18181b" - radius: 6 - border.width: root.activeFocus ? 1 : 0 - border.color: "#f26a21" + color: root.isPrimary || root.hovered ? "#27272a" : "#18181b" + radius: 8 + border.width: root.activeFocus || root.isPrimary ? 1 : 0 + border.color: root.isPrimary ? "#f59e0b" : "#52525b" } contentItem: ColumnLayout { - spacing: 6 + spacing: 7 RowLayout { Layout.fillWidth: true - spacing: 8 + spacing: 7 Label { + Layout.fillWidth: true text: root.name - color: "#f4f4f5" + color: "#fafafa" font.bold: true + elide: Text.ElideRight } Label { - text: root.isPublic ? qsTr("Public") : qsTr("Private") - color: "#a1a1aa" + visible: root.isPrimary + text: qsTr("Primary") + color: "#fbbf24" font.pixelSize: 11 + font.bold: true } - Item { Layout.fillWidth: true } + Label { + text: root.kindLabel() + color: "#a1a1aa" + font.pixelSize: 11 + } Label { - text: root.balance.length > 0 ? root.balance : "-" - color: "#f4f4f5" - font.bold: true + text: root.visibility === "private" ? qsTr("Private") : qsTr("Public") + color: root.visibility === "private" ? "#c4b5fd" : "#93c5fd" + font.pixelSize: 11 } } + Label { + visible: root.programName.length > 0 + Layout.fillWidth: true + text: qsTr("%1 program · wallet controlled").arg(root.programName) + color: "#a1a1aa" + font.pixelSize: 11 + elide: Text.ElideRight + } + RowLayout { Layout.fillWidth: true spacing: 4 Label { Layout.fillWidth: true - text: root.address - color: "#a1a1aa" + text: root.displayAddress + color: "#71717a" font.family: "monospace" font.pixelSize: 11 elide: Text.ElideMiddle } CopyButton { - visible: root.address.length > 0 - onCopyRequested: root.copyRequested(root.address) + visible: root.displayAddress.length > 0 + onCopyRequested: root.copyRequested(root.displayAddress) } } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Button { + text: qsTr("Rename") + flat: true + onClicked: root.renameRequested(root.address, root.alias) + } + + Item { Layout.fillWidth: true } + + Button { + visible: root.canBePrimary && !root.isPrimary + text: qsTr("Make primary") + flat: true + onClicked: root.makePrimaryRequested(root.address) + } + } + } + + onClicked: { + if (root.canBePrimary && !root.isPrimary) + root.makePrimaryRequested(root.address) } } diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp index 3e569525..f23321f1 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.cpp +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -73,6 +74,46 @@ 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; +} } struct LogosWalletProvider::Impl { @@ -130,6 +171,76 @@ 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; + } + m_impl->logos->logos_execution_zone.list_accountsAsync( + [this, generation, finishOpen, openStored](QVariantList accounts) mutable { + if (generation != m_generation) + return; + if (!accounts.isEmpty()) + finishOpen(true, WalletFailure::None); + else + openStored(); + }); + }); +} + WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, const QString& password) { @@ -180,6 +291,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 = {}; @@ -208,45 +339,57 @@ WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic) 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; + return WalletAccountRead { accountId }; + return parsePublicAccount( + accountId, + m_impl->logos->logos_execution_zone.get_account_public(accountId)); +} - const QJsonObject account = document.object(); - const QString owner = account.value(QStringLiteral("program_owner")).toString(); - const QString balance = account.value(QStringLiteral("balance")).toString(); - const QString nonce = account.value(QStringLiteral("nonce")).toString(); - const QString data = account.value(QStringLiteral("data")).toString(); - if (!isHex(owner, 64) - || !isHex(balance, 32) - || !isHex(nonce, 32) - || data.size() % 2 != 0 - || !isHex(data, data.size())) { - return read; +void LogosWalletProvider::readPublicAccountsAsync( + const QStringList& accountIds, + AccountReadsCallback callback) +{ + if (!m_impl->logos || accountIds.isEmpty()) { + QTimer::singleShot(0, + [callback = std::move(callback)]() mutable { callback({}); }); + return; } - read.status = QStringLiteral("ok"); - read.programOwner = owner; - read.balanceHex = balance; - read.nonceHex = nonce; - read.dataHex = data; - return read; + struct BatchState { + QVector reads; + qsizetype remaining = 0; + AccountReadsCallback callback; + }; + const quint64 generation = m_generation; + auto state = std::make_shared(); + state->reads.resize(accountIds.size()); + state->remaining = accountIds.size(); + state->callback = std::move(callback); + for (qsizetype index = 0; index < accountIds.size(); ++index) { + const QString accountId = accountIds.at(index); + if (!isHex(accountId, 64)) { + state->reads[index] = WalletAccountRead { accountId }; + if (--state->remaining == 0) + state->callback(std::move(state->reads)); + continue; + } + m_impl->logos->logos_execution_zone.get_account_publicAsync( + accountId, + [this, generation, state, index, accountId](QString payload) mutable { + if (generation != m_generation) + return; + state->reads[index] = parsePublicAccount(accountId, payload); + if (--state->remaining == 0) + state->callback(std::move(state->reads)); + }); + } } WalletSubmission LogosWalletProvider::submitPublicTransaction( @@ -314,6 +457,7 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction( void LogosWalletProvider::disconnect() { + ++m_generation; if (m_connected) save(); clearSnapshot(); @@ -361,20 +505,181 @@ WalletSnapshot LogosWalletProvider::loadSnapshot() if (account.isPublic) { const WalletAccountRead read = readPublicAccount(address); result.publicAccountReads.append(read); + applyPublicRead(account, read); account.balance = read.ok() ? littleEndianU128ToDecimal(read.balanceHex) : m_impl->logos->logos_execution_zone.get_balance(address, true); } else { + account.readStatus = QStringLiteral("private"); account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false); } result.accounts.append(account); } - if (!save()) - result.failure = WalletFailure::SaveFailed; return result; } +void LogosWalletProvider::loadSnapshotAsync(quint64 generation, SnapshotCallback callback) +{ + if (!m_impl->logos || generation != m_generation) + return; + + m_impl->logos->logos_execution_zone.get_current_block_heightAsync( + [this, generation, callback = std::move(callback)](int currentHeight) mutable { + if (generation != m_generation) + return; + + auto afterSync = [this, generation, currentHeight, + callback = std::move(callback)](int syncResult) mutable { + if (generation != m_generation) + return; + if (syncResult != WALLET_FFI_SUCCESS) { + WalletSnapshot failed; + failed.failure = WalletFailure::ReadFailed; + callback(std::move(failed)); + return; + } + + m_impl->logos->logos_execution_zone.get_last_synced_blockAsync( + [this, generation, currentHeight, + callback = std::move(callback)](int lastSynced) mutable { + if (generation != m_generation) + return; + m_impl->logos->logos_execution_zone.get_sequencer_addrAsync( + [this, generation, currentHeight, lastSynced, + callback = std::move(callback)](QString address) mutable { + if (generation != m_generation) + return; + m_impl->logos->logos_execution_zone.list_accountsAsync( + [this, generation, currentHeight, lastSynced, + address = std::move(address), + callback = std::move(callback)]( + QVariantList entries) mutable { + if (generation != m_generation) + return; + + struct SnapshotState { + WalletSnapshot snapshot; + QVector publicReads; + QVector publicFlags; + qsizetype remaining = 0; + SnapshotCallback callback; + }; + auto state = std::make_shared(); + state->snapshot.currentBlockHeight = static_cast( + qMax(0, currentHeight)); + state->snapshot.lastSyncedBlock = static_cast( + qMax(0, lastSynced)); + state->snapshot.sequencerAddress = std::move(address); + state->snapshot.accounts.resize(entries.size()); + state->publicReads.resize(entries.size()); + state->publicFlags.resize(entries.size()); + state->remaining = entries.size(); + state->callback = std::move(callback); + + for (qsizetype index = 0; index < entries.size(); ++index) { + const QVariantMap entry = entries.at(index).toMap(); + const QString accountId = entry + .value(QStringLiteral("account_id")).toString(); + if (entry.isEmpty() || !isHex(accountId, 64)) { + state->snapshot.failure = WalletFailure::ReadFailed; + state->callback(std::move(state->snapshot)); + return; + } + state->snapshot.accounts[index] = WalletAccount { + accountId, + {}, + entry.value(QStringLiteral("is_public"), true).toBool(), + }; + if (!state->snapshot.accounts.at(index).isPublic) { + state->snapshot.accounts[index].readStatus = + QStringLiteral("private"); + } + state->publicFlags[index] = + state->snapshot.accounts.at(index).isPublic; + } + + auto finishOne = std::make_shared>(); + *finishOne = [this, generation, state, finishOne]() mutable { + 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)); + } + m_impl->logos->logos_execution_zone.saveAsync( + [this, generation, state](int result) mutable { + if (generation != m_generation) + return; + if (result != WALLET_FFI_SUCCESS) + state->snapshot.failure = WalletFailure::SaveFailed; + if (state->snapshot.ok()) { + m_snapshot = state->snapshot; + m_snapshotReady = true; + } + state->callback(std::move(state->snapshot)); + }); + }; + + if (entries.isEmpty()) { + state->remaining = 1; + (*finishOne)(); + return; + } + + for (qsizetype index = 0; index < entries.size(); ++index) { + const WalletAccount account = state->snapshot.accounts.at(index); + if (!account.isPublic) { + m_impl->logos->logos_execution_zone.get_balanceAsync( + account.address, false, + [state, finishOne, index](QString balance) { + state->snapshot.accounts[index].balance = + std::move(balance); + (*finishOne)(); + }); + continue; + } + + m_impl->logos->logos_execution_zone.get_account_publicAsync( + account.address, + [this, state, finishOne, index, + accountId = account.address](QString payload) { + const WalletAccountRead read = + parsePublicAccount(accountId, payload); + state->publicReads[index] = read; + applyPublicRead( + state->snapshot.accounts[index], read); + if (read.ok()) { + state->snapshot.accounts[index].balance = + littleEndianU128ToDecimal(read.balanceHex); + (*finishOne)(); + return; + } + m_impl->logos->logos_execution_zone.get_balanceAsync( + accountId, true, + [state, finishOne, index](QString balance) { + state->snapshot.accounts[index].balance = + std::move(balance); + (*finishOne)(); + }); + }); + } + }); + }); + }); + }; + + if (currentHeight > 0) { + m_impl->logos->logos_execution_zone.sync_to_blockAsync( + currentHeight, std::move(afterSync)); + } else { + afterSync(WALLET_FFI_SUCCESS); + } + }); +} + bool LogosWalletProvider::save() const { return m_impl->logos diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h index d0462906..a22aac8e 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.h +++ b/apps/shared/wallet/src/LogosWalletProvider.h @@ -14,12 +14,16 @@ class LogosWalletProvider final : public WalletProvider { ~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; + void readPublicAccountsAsync(const QStringList& accountIds, + AccountReadsCallback callback) override; WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) override; void disconnect() override; @@ -27,6 +31,7 @@ class LogosWalletProvider final : public WalletProvider { private: bool sharedWalletIsOpen() const; WalletSnapshot loadSnapshot(); + void loadSnapshotAsync(quint64 generation, SnapshotCallback callback); bool save() const; struct Impl; @@ -34,4 +39,5 @@ class LogosWalletProvider final : public WalletProvider { WalletSnapshot m_snapshot; bool m_snapshotReady = false; bool m_connected = false; + quint64 m_generation = 0; }; diff --git a/apps/shared/wallet/src/WalletAccountId.cpp b/apps/shared/wallet/src/WalletAccountId.cpp new file mode 100644 index 00000000..76903c70 --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountId.cpp @@ -0,0 +1,56 @@ +#include "WalletAccountId.h" + +#include +#include + +namespace { +constexpr char BASE58_ALPHABET[] = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +bool isHexCharacter(QChar character) +{ + const ushort value = character.unicode(); + return (value >= '0' && value <= '9') + || (value >= 'a' && value <= 'f') + || (value >= 'A' && value <= 'F'); +} +} + +QString walletAccountIdToBase58(const QString& accountId) +{ + if (accountId.size() != 64) + return {}; + for (const QChar character : accountId) { + if (!isHexCharacter(character)) + return {}; + } + + const QByteArray bytes = QByteArray::fromHex(accountId.toLatin1()); + if (bytes.size() != 32) + return {}; + + qsizetype leadingZeroes = 0; + while (leadingZeroes < bytes.size() && bytes.at(leadingZeroes) == 0) + ++leadingZeroes; + + QVector digits; + digits.reserve(45); + for (const char byte : bytes) { + int carry = static_cast(byte); + for (unsigned char& digit : digits) { + carry += static_cast(digit) * 256; + digit = static_cast(carry % 58); + carry /= 58; + } + while (carry > 0) { + digits.append(static_cast(carry % 58)); + carry /= 58; + } + } + + QString encoded(leadingZeroes, QLatin1Char('1')); + encoded.reserve(leadingZeroes + digits.size()); + for (auto digit = digits.crbegin(); digit != digits.crend(); ++digit) + encoded.append(QLatin1Char(BASE58_ALPHABET[*digit])); + return encoded; +} diff --git a/apps/shared/wallet/src/WalletAccountId.h b/apps/shared/wallet/src/WalletAccountId.h new file mode 100644 index 00000000..ad7cf3e2 --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountId.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +QString walletAccountIdToBase58(const QString& accountId); diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp index 06e94bfd..615f4b75 100644 --- a/apps/shared/wallet/src/WalletAccountModel.cpp +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -1,5 +1,13 @@ #include "WalletAccountModel.h" +#include "WalletAccountId.h" + +#include + +namespace { +const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0')); +} + WalletAccountModel::WalletAccountModel(QObject* parent) : QAbstractListModel(parent) { @@ -21,10 +29,36 @@ QVariant WalletAccountModel::data(const QModelIndex& index, int role) const return account.name; case AddressRole: return account.address; + case DisplayAddressRole: + return account.displayAddress; case BalanceRole: return account.balance; case IsPublicRole: return account.isPublic; + case KindRole: + return account.kind; + case SectionRole: + return account.section; + case ProgramOwnerRole: + return account.programOwner; + case ReadStatusRole: + return account.readStatus; + case ProgramNameRole: + return account.programName; + case AccountTypeRole: + return account.accountType; + case VisibilityRole: + return account.isPublic ? QStringLiteral("public") : QStringLiteral("private"); + case ControlRole: + return QStringLiteral("wallet"); + case CanBePrimaryRole: + return account.canBePrimary; + case IsPrimaryRole: + return account.isPrimary; + case DefinitionIdRole: + return account.definitionId; + case AliasRole: + return account.alias; default: return {}; } @@ -37,25 +71,170 @@ QHash WalletAccountModel::roleNames() const { AddressRole, "address" }, { BalanceRole, "balance" }, { IsPublicRole, "isPublic" }, + { KindRole, "kind" }, + { SectionRole, "section" }, + { ProgramOwnerRole, "programOwner" }, + { ReadStatusRole, "readStatus" }, + { ProgramNameRole, "programName" }, + { AccountTypeRole, "accountType" }, + { VisibilityRole, "visibility" }, + { ControlRole, "control" }, + { CanBePrimaryRole, "canBePrimary" }, + { IsPrimaryRole, "isPrimary" }, + { DefinitionIdRole, "definitionId" }, + { AliasRole, "alias" }, + { DisplayAddressRole, "displayAddress" }, }; } -void WalletAccountModel::replaceAccounts(const QVector& accounts) +void WalletAccountModel::replaceAccounts(const QVector& accounts, + const QHash& aliases, + const QString& primaryAddress) { beginResetModel(); const qsizetype oldCount = m_accounts.size(); m_accounts.clear(); m_accounts.reserve(accounts.size()); - for (qsizetype index = 0; index < accounts.size(); ++index) { - const WalletAccount& account = accounts.at(index); - m_accounts.append({ - QStringLiteral("Account %1").arg(index + 1), - account.address, - account.balance, - account.isPublic, - }); + for (const WalletAccount& account : accounts) { + Entry entry; + entry.alias = aliases.value(account.address); + entry.address = account.address; + entry.displayAddress = walletAccountIdToBase58(account.address); + if (entry.displayAddress.isEmpty()) + entry.displayAddress = account.address; + entry.balance = account.balance; + entry.isPublic = account.isPublic; + entry.programOwner = account.programOwner; + entry.readStatus = account.readStatus; + if (!account.isPublic) { + entry.kind = QStringLiteral("private"); + entry.canBePrimary = true; + } else if (account.readStatus != QStringLiteral("ok")) { + entry.kind = QStringLiteral("unknown"); + } else if (account.programOwner == DEFAULT_PROGRAM_OWNER) { + entry.kind = QStringLiteral("user"); + entry.canBePrimary = true; + } else { + entry.kind = QStringLiteral("program"); + } + entry.section = sectionFor(entry); + entry.isPrimary = account.address == primaryAddress && entry.canBePrimary; + updateEntryName(entry); + m_accounts.append(std::move(entry)); } endResetModel(); if (oldCount != m_accounts.size()) emit countChanged(); } + +void WalletAccountModel::applyPresentations( + const QVector& presentations) +{ + for (const WalletAccountPresentation& presentation : presentations) { + const int row = indexOf(presentation.address); + if (row < 0) + continue; + Entry& entry = m_accounts[row]; + if (!presentation.kind.isEmpty()) + entry.kind = presentation.kind; + entry.programName = presentation.programName; + entry.accountType = presentation.accountType; + entry.definitionId = presentation.definitionId; + entry.semanticName = presentation.semanticName; + entry.section = sectionFor(entry, presentation.hiddenFromAccounts); + entry.canBePrimary = entry.kind == QStringLiteral("user") + || entry.kind == QStringLiteral("private"); + if (!entry.canBePrimary) + entry.isPrimary = false; + updateEntryName(entry); + const QModelIndex changed = index(row); + emit dataChanged(changed, changed); + } +} + +void WalletAccountModel::setAlias(const QString& address, const QString& alias) +{ + const int row = indexOf(address); + if (row < 0) + return; + Entry& entry = m_accounts[row]; + entry.alias = alias; + updateEntryName(entry); + emit dataChanged(index(row), index(row), { NameRole, AliasRole }); +} + +void WalletAccountModel::setPrimaryAddress(const QString& address) +{ + for (int row = 0; row < m_accounts.size(); ++row) { + Entry& entry = m_accounts[row]; + const bool next = entry.address == address && entry.canBePrimary; + if (entry.isPrimary == next) + continue; + entry.isPrimary = next; + emit dataChanged(index(row), index(row), { IsPrimaryRole }); + } +} + +bool WalletAccountModel::contains(const QString& address) const +{ + return indexOf(address) >= 0; +} + +bool WalletAccountModel::canBePrimary(const QString& address) const +{ + const int row = indexOf(address); + return row >= 0 && m_accounts.at(row).canBePrimary; +} + +QString WalletAccountModel::firstAutomaticPrimary() const +{ + for (const Entry& entry : m_accounts) { + if (entry.kind == QStringLiteral("user")) + return entry.address; + } + return {}; +} + +int WalletAccountModel::indexOf(const QString& address) const +{ + for (int row = 0; row < m_accounts.size(); ++row) { + if (m_accounts.at(row).address == address) + return row; + } + return -1; +} + +QString WalletAccountModel::defaultName(const Entry& entry) +{ + if (!entry.accountType.isEmpty()) { + QString name = entry.accountType; + for (qsizetype index = 1; index < name.size(); ++index) { + if (name.at(index).isUpper() && name.at(index - 1).isLower()) + name.insert(index++, QLatin1Char(' ')); + } + return name; + } + if (entry.kind == QStringLiteral("user")) + return QStringLiteral("User account"); + if (entry.kind == QStringLiteral("private")) + return QStringLiteral("Private account"); + if (entry.kind == QStringLiteral("unknown")) + return QStringLiteral("Unknown account"); + return QStringLiteral("Program account"); +} + +QString WalletAccountModel::sectionFor(const Entry& entry, bool hiddenFromAccounts) +{ + if (hiddenFromAccounts || entry.kind == QStringLiteral("token_holding")) + return QStringLiteral("hidden"); + if (entry.kind == QStringLiteral("user") || entry.kind == QStringLiteral("private")) + return QStringLiteral("accounts"); + return QStringLiteral("advanced"); +} + +void WalletAccountModel::updateEntryName(Entry& entry) +{ + entry.name = !entry.alias.isEmpty() + ? entry.alias + : (!entry.semanticName.isEmpty() ? entry.semanticName : defaultName(entry)); +} diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h index c00b0d65..86acf4fa 100644 --- a/apps/shared/wallet/src/WalletAccountModel.h +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -1,10 +1,21 @@ #pragma once #include +#include #include #include "WalletProvider.h" +struct WalletAccountPresentation { + QString address; + QString kind; + QString semanticName; + QString programName; + QString accountType; + QString definitionId; + bool hiddenFromAccounts = false; +}; + class WalletAccountModel final : public QAbstractListModel { Q_OBJECT Q_PROPERTY(int count READ count NOTIFY countChanged) @@ -15,6 +26,19 @@ class WalletAccountModel final : public QAbstractListModel { AddressRole, BalanceRole, IsPublicRole, + KindRole, + SectionRole, + ProgramOwnerRole, + ReadStatusRole, + ProgramNameRole, + AccountTypeRole, + VisibilityRole, + ControlRole, + CanBePrimaryRole, + IsPrimaryRole, + DefinitionIdRole, + AliasRole, + DisplayAddressRole, }; Q_ENUM(Role) @@ -24,7 +48,16 @@ class WalletAccountModel final : public QAbstractListModel { QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - void replaceAccounts(const QVector& accounts); + void replaceAccounts(const QVector& accounts, + const QHash& aliases = {}, + const QString& primaryAddress = {}); + void applyPresentations(const QVector& presentations); + void setAlias(const QString& address, const QString& alias); + void setPrimaryAddress(const QString& address); + bool contains(const QString& address) const; + bool canBePrimary(const QString& address) const; + QString firstAutomaticPrimary() const; + int indexOf(const QString& address) const; int count() const { return m_accounts.size(); } signals: @@ -32,11 +65,27 @@ class WalletAccountModel final : public QAbstractListModel { private: struct Entry { + QString alias; + QString semanticName; QString name; QString address; + QString displayAddress; QString balance; bool isPublic = true; + QString kind; + QString section; + QString programOwner; + QString readStatus; + QString programName; + QString accountType; + QString definitionId; + bool canBePrimary = false; + bool isPrimary = false; }; + static QString defaultName(const Entry& entry); + static QString sectionFor(const Entry& entry, bool hiddenFromAccounts = false); + void updateEntryName(Entry& entry); + QVector m_accounts; }; diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp index 5cffdfd5..ddf915b6 100644 --- a/apps/shared/wallet/src/WalletController.cpp +++ b/apps/shared/wallet/src/WalletController.cpp @@ -3,8 +3,12 @@ #include #include +#include #include #include +#include +#include +#include #include #include #include @@ -18,6 +22,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 +33,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 +66,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, @@ -83,20 +114,60 @@ 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(); + } + }); + m_wallet.connectAsync({ config, storage }, + [this, generation, config, storage](WalletSession session) { + if (generation != m_operationGeneration) + return; + if (session.failure == WalletFailure::WalletMissing) { + m_state.syncStatus = QStringLiteral("closed"); + m_state.walletExists = false; + emit stateChanged(); + return; + } + if (!session.ok()) { + qWarning() << "WalletController: wallet connection failed" + << walletFailureCode(session.failure); + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(session.failure); + emit stateChanged(); + return; + } + + m_state.configPath = config; + m_state.storagePath = storage; + m_state.walletExists = QFileInfo::exists(storage) || session.adopted; + m_state.isWalletOpen = true; + m_state.syncStatus = QStringLiteral("ready"); + applySnapshot(session.snapshot); + }); + return true; } QString WalletController::createDefaultWallet(const QString& password) @@ -112,7 +183,9 @@ QString WalletController::createWallet(const QString& configPath, const QString storage = toLocalPath(storagePath); const WalletCreation creation = m_wallet.createWallet( { config, storage }, password); - if (creation.mnemonic.isEmpty()) { + const bool createdButUnreadable = creation.failure == WalletFailure::ReadFailed; + if (creation.mnemonic.isEmpty() + || (!creation.ok() && !createdButUnreadable)) { qWarning() << "WalletController: wallet creation failed" << walletFailureCode(creation.failure); return {}; @@ -122,14 +195,18 @@ QString WalletController::createWallet(const QString& configPath, 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.isWalletOpen = true; + if (!creation.snapshot.ok()) { + qWarning() << "WalletController: wallet creation refresh failed" + << walletFailureCode(creation.snapshot.failure); + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(creation.snapshot.failure); emit stateChanged(); return creation.mnemonic; } - m_state.isWalletOpen = true; + m_state.syncStatus = QStringLiteral("ready"); + m_state.syncError.clear(); applySnapshot(creation.snapshot); return creation.mnemonic; } @@ -140,29 +217,67 @@ 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; m_wallet.disconnect(); m_state.isWalletOpen = false; + m_state.syncStatus = QStringLiteral("closed"); + m_state.syncError.clear(); + m_state.primaryAccountAddress.clear(); + m_state.primaryAccountName.clear(); + m_snapshot = {}; + m_aliases.clear(); m_accountModel->replaceAccounts({}); QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); emit stateChanged(); + emit snapshotChanged(); +} + +bool WalletController::setAccountAlias(const QString& address, const QString& alias) +{ + if (!m_accountModel->contains(address)) + return false; + const QString normalized = alias.trimmed(); + if (normalized.size() > MAX_ALIAS_LENGTH) + return false; + if (normalized.isEmpty()) + m_aliases.remove(address); + else + m_aliases.insert(address, normalized); + m_accountModel->setAlias(address, normalized); + storeAliases(m_aliases); + updatePrimaryState(m_state.primaryAccountAddress); + emit stateChanged(); + return true; +} + +bool WalletController::setPrimaryAccount(const QString& address) +{ + if (!m_accountModel->canBePrimary(address)) + return false; + m_accountModel->setPrimaryAddress(address); + storePrimaryAccount(address); + updatePrimaryState(address); + emit stateChanged(); + return true; +} + +void WalletController::applyAccountPresentations( + const QVector& presentations) +{ + m_accountModel->applyPresentations(presentations); + QString primary = m_state.primaryAccountAddress; + if (!m_accountModel->canBePrimary(primary)) + primary = m_accountModel->firstAutomaticPrimary(); + m_accountModel->setPrimaryAddress(primary); + storePrimaryAccount(primary); + updatePrimaryState(primary); + emit stateChanged(); } QString WalletController::createAccount(bool isPublic) @@ -174,23 +289,41 @@ 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(); + m_wallet.snapshotAsync(true, [this, generation](WalletSnapshot next) { + if (generation != m_operationGeneration) + return; + if (next.ok()) { + m_state.syncStatus = QStringLiteral("ready"); + applySnapshot(next); + } else { + qWarning() << "WalletController: wallet refresh failed" + << walletFailureCode(next.failure); + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(next.failure); + emit stateChanged(); + } + }); } QString WalletController::balance(const QString& accountId, bool isPublic) @@ -205,14 +338,85 @@ QString WalletController::balance(const QString& accountId, bool isPublic) void WalletController::applySnapshot(const WalletSnapshot& snapshot) { - m_accountModel->replaceAccounts(snapshot.accounts); + m_snapshot = snapshot; + m_aliases = loadAliases(); + QString primary = loadPrimaryAccount(); + m_accountModel->replaceAccounts(snapshot.accounts, m_aliases, primary); + if (!m_accountModel->canBePrimary(primary)) + primary = m_accountModel->firstAutomaticPrimary(); + m_accountModel->setPrimaryAddress(primary); + storePrimaryAccount(primary); + updatePrimaryState(primary); m_state.lastSyncedBlock = static_cast(snapshot.lastSyncedBlock); m_state.currentBlockHeight = static_cast(snapshot.currentBlockHeight); - m_state.sequencerAddress = snapshot.sequencerAddress; + if (!snapshot.sequencerAddress.isEmpty()) + m_state.sequencerAddress = snapshot.sequencerAddress; + emit snapshotChanged(); emit stateChanged(); checkReachability(); } +QString WalletController::walletSettingsGroup() const +{ + const QByteArray hash = QCryptographicHash::hash( + canonicalStoragePath(m_state.storagePath).toUtf8(), + QCryptographicHash::Sha256).toHex(); + return QStringLiteral("%1/%2") + .arg(QString::fromLatin1(WALLET_SETTINGS_GROUP), QString::fromLatin1(hash)); +} + +QHash WalletController::loadAliases() const +{ + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + const QVariantMap stored = settings.value(ALIASES_KEY).toMap(); + QHash aliases; + for (auto iterator = stored.cbegin(); iterator != stored.cend(); ++iterator) { + const QString alias = iterator.value().toString().trimmed(); + if (!alias.isEmpty() && alias.size() <= MAX_ALIAS_LENGTH) + aliases.insert(iterator.key(), alias); + } + return aliases; +} + +QString WalletController::loadPrimaryAccount() const +{ + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + return settings.value(PRIMARY_ACCOUNT_KEY).toString(); +} + +void WalletController::storeAliases(const QHash& aliases) const +{ + QVariantMap stored; + for (auto iterator = aliases.cbegin(); iterator != aliases.cend(); ++iterator) + stored.insert(iterator.key(), iterator.value()); + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + settings.setValue(ALIASES_KEY, stored); +} + +void WalletController::storePrimaryAccount(const QString& address) const +{ + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + if (address.isEmpty()) + settings.remove(PRIMARY_ACCOUNT_KEY); + else + settings.setValue(PRIMARY_ACCOUNT_KEY, address); +} + +void WalletController::updatePrimaryState(const QString& address) +{ + m_state.primaryAccountAddress = address; + m_state.primaryAccountName.clear(); + const int row = m_accountModel->indexOf(address); + if (row >= 0) { + m_state.primaryAccountName = m_accountModel->data( + m_accountModel->index(row), WalletAccountModel::NameRole).toString(); + } +} + void WalletController::checkReachability() { if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty()) diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h index c27f9cef..6d8ae82f 100644 --- a/apps/shared/wallet/src/WalletController.h +++ b/apps/shared/wallet/src/WalletController.h @@ -1,13 +1,16 @@ #pragma once #include +#include #include +#include #include "WalletProvider.h" class QNetworkAccessManager; class QTimer; class WalletAccountModel; +struct WalletAccountPresentation; struct WalletUiState { bool isWalletOpen = false; @@ -19,6 +22,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,6 +45,7 @@ class WalletController final : public QObject { WalletAccountModel* accountModel() const { return m_accountModel; } const WalletUiState& state() const { return m_state; } + const WalletSnapshot& snapshot() const { return m_snapshot; } void start(); QString createAccount(bool isPublic); @@ -44,9 +57,14 @@ class WalletController final : public QObject { const QString& password); bool open(); void disconnect(); + bool setAccountAlias(const QString& address, const QString& alias); + bool setPrimaryAccount(const QString& address); + void applyAccountPresentations( + const QVector& presentations); signals: void stateChanged(); + void snapshotChanged(); private: static QString defaultWalletHome(); @@ -54,14 +72,24 @@ class WalletController final : public QObject { QString defaultStoragePath() const; void openOnStartup(); + bool beginOpen(const QString& config, const QString& storage); void applySnapshot(const WalletSnapshot& snapshot); void checkReachability(); + QString walletSettingsGroup() const; + QHash loadAliases() const; + QString loadPrimaryAccount() const; + void storeAliases(const QHash& aliases) const; + void storePrimaryAccount(const QString& address) const; + void updatePrimaryState(const QString& address); WalletProvider& m_wallet; QString m_settingsApplication; WalletUiState m_state; + WalletSnapshot m_snapshot; + QHash m_aliases; WalletAccountModel* m_accountModel; QNetworkAccessManager* m_network; QTimer* m_reachabilityTimer; bool m_started = false; + quint64 m_operationGeneration = 0; }; diff --git a/apps/shared/wallet/src/WalletProvider.h b/apps/shared/wallet/src/WalletProvider.h index a7814caa..94fb2258 100644 --- a/apps/shared/wallet/src/WalletProvider.h +++ b/apps/shared/wallet/src/WalletProvider.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -38,6 +39,9 @@ struct WalletAccount { QString address; QString balance; bool isPublic = true; + QString readStatus; + QString programOwner; + QString dataHex; }; struct WalletSnapshot { @@ -92,15 +96,23 @@ struct WalletSubmission { class WalletProvider { public: + using SessionCallback = std::function; + using SnapshotCallback = std::function; + using AccountReadsCallback = std::function)>; + virtual ~WalletProvider() = default; virtual WalletSession connect(const WalletPaths& paths) = 0; + virtual void connectAsync(const WalletPaths& paths, SessionCallback callback) = 0; virtual WalletCreation createWallet(const WalletPaths& paths, const QString& password) = 0; virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0; + virtual void snapshotAsync(bool forceRefresh, SnapshotCallback callback) = 0; virtual void clearSnapshot() = 0; virtual WalletAccountCreation createAccount(bool isPublic) = 0; virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0; + virtual void readPublicAccountsAsync(const QStringList& accountIds, + AccountReadsCallback callback) = 0; virtual WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) = 0; virtual void disconnect() = 0; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index 9701d6b5..3be65657 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -10,6 +10,7 @@ #include "FakeWalletProvider.h" #include "LogosWalletProvider.h" +#include "WalletAccountId.h" #include "WalletAccountModel.h" #include "WalletController.h" #include "logos_sdk.h" @@ -17,7 +18,9 @@ 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')); QString publicAccountJson(const QString& owner = PROGRAM_ID, const QString& balance = QStringLiteral("01000000000000000000000000000000"), @@ -39,6 +42,7 @@ QVariantMap accountEntry(const QString& id, bool isPublic) { QStringLiteral("is_public"), isPublic }, }; } + } class LogosWalletProviderTest : public QObject { @@ -47,6 +51,7 @@ class LogosWalletProviderTest : public QObject { private slots: void adoptsOpenWalletAndCachesSnapshots(); void opensConfiguredWalletWhenNoSharedSessionExists(); + void opensAndReadsAsynchronously(); void createsAndPersistsWallet(); void validatesCompletePublicAccountPayloads(); void fallsBackToBalanceWhenPublicReadFails(); @@ -55,9 +60,12 @@ private slots: void preservesCreatedAccountWhenSnapshotRefreshFails(); void dispatchesExactGenericTransaction(); void rejectsInvalidSubmissionResponses(); + void encodesAccountIdsForDisplay(); void exposesStableAccountModelRoles(); + void persistsHumanizedWalletPreferences(); void fakeProviderImplementsConsumerContract(); void controllerOwnsUiWalletFlow(); + void controllerReportsCreationPersistenceAndRefreshFailures(); void controllerStopsReachabilityChecksAfterDisconnect(); }; @@ -84,6 +92,10 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1")); QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42")); QCOMPARE(session.snapshot.publicAccountReads.size(), 1); + QCOMPARE(session.snapshot.accounts.at(0).readStatus, QStringLiteral("ok")); + QCOMPARE(session.snapshot.accounts.at(0).programOwner, PROGRAM_ID); + QCOMPARE(session.snapshot.accounts.at(0).dataHex, QStringLiteral("00ff")); + QCOMPARE(session.snapshot.accounts.at(1).readStatus, QStringLiteral("private")); QCOMPARE(session.snapshot.currentBlockHeight, quint64(12)); QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11)); @@ -138,6 +150,38 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists() WalletFailure::WalletMissing); } +void LogosWalletProviderTest::opensAndReadsAsynchronously() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.publicAccounts.insert( + ACCOUNT_A, publicAccountJson(EOA_OWNER)); + LogosWalletProvider provider(&modules); + + bool connected = false; + provider.connectAsync({}, [&connected](WalletSession session) { + connected = session.ok() && session.snapshot.accounts.size() == 1; + }); + QVERIFY(connected); + + bool refreshed = false; + provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) { + refreshed = snapshot.ok() && snapshot.accounts.at(0).programOwner == EOA_OWNER; + }); + QVERIFY(refreshed); + + bool batchRead = false; + provider.readPublicAccountsAsync( + { ACCOUNT_A, ACCOUNT_B }, + [&batchRead](QVector reads) { + batchRead = reads.size() == 2 + && reads.at(0).ok() + && !reads.at(1).ok(); + }); + QVERIFY(batchRead); +} + void LogosWalletProviderTest::createsAndPersistsWallet() { QTemporaryDir directory; @@ -338,19 +382,92 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() WalletAccountModel model; QSignalSpy countChanged(&model, &WalletAccountModel::countChanged); model.replaceAccounts({ - { ACCOUNT_A, QStringLiteral("10"), true }, - { ACCOUNT_B, QStringLiteral("20"), false }, - }); + { ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} }, + { ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} }, + { ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, QStringLiteral("00") }, + }, { { ACCOUNT_A, QStringLiteral("Trading") } }, ACCOUNT_A); - QCOMPARE(model.count(), 2); + QCOMPARE(model.count(), 3); QCOMPARE(countChanged.count(), 1); QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name")); QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(), - QStringLiteral("Account 1")); + QStringLiteral("Trading")); + QCOMPARE(model.data(model.index(0), WalletAccountModel::KindRole).toString(), + QStringLiteral("user")); + QVERIFY(model.data(model.index(0), WalletAccountModel::CanBePrimaryRole).toBool()); + QVERIFY(model.data(model.index(0), WalletAccountModel::IsPrimaryRole).toBool()); QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B); + QCOMPARE(model.roleNames().value(WalletAccountModel::DisplayAddressRole), + QByteArray("displayAddress")); + QCOMPARE(model.data(model.index(1), WalletAccountModel::DisplayAddressRole).toString(), + walletAccountIdToBase58(ACCOUNT_B)); QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(), QStringLiteral("20")); QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool()); + QCOMPARE(model.data(model.index(2), WalletAccountModel::KindRole).toString(), + QStringLiteral("program")); + QVERIFY(!model.data(model.index(2), WalletAccountModel::CanBePrimaryRole).toBool()); + + model.applyPresentations({ { + ACCOUNT_C, + QStringLiteral("token_holding"), + QStringLiteral("TEST holding"), + QStringLiteral("Token"), + QStringLiteral("TokenHolding"), + ACCOUNT_A, + true, + } }); + QCOMPARE(model.data(model.index(2), WalletAccountModel::SectionRole).toString(), + QStringLiteral("hidden")); + QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), + QStringLiteral("TEST holding")); + model.setAlias(ACCOUNT_C, QStringLiteral("Reserve")); + QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), + QStringLiteral("Reserve")); + model.setAlias(ACCOUNT_C, {}); + QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), + QStringLiteral("TEST holding")); +} + +void LogosWalletProviderTest::encodesAccountIdsForDisplay() +{ + QCOMPARE(walletAccountIdToBase58( + QStringLiteral("00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb")), + QStringLiteral("14tAtixMByFyJrcZVyWibitnijLgd59PfyrjdnYzo8La")); + QCOMPARE(walletAccountIdToBase58(QString(64, QLatin1Char('0'))), + QString(32, QLatin1Char('1'))); + QVERIFY(walletAccountIdToBase58(QStringLiteral("not-an-account-id")).isEmpty()); +} + +void LogosWalletProviderTest::persistsHumanizedWalletPreferences() +{ + const QString application = QStringLiteral("HumanizedWalletPreferencesTest"); + QSettings settings(QStringLiteral("Logos"), application); + settings.clear(); + FakeWalletProvider provider; + provider.connectResult.adopted = true; + provider.connectResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} }, + { ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} }, + { ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, {} }, + }; + + { + WalletController controller(provider, application); + QVERIFY(controller.open()); + QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A); + QVERIFY(!controller.setPrimaryAccount(ACCOUNT_C)); + QVERIFY(controller.setAccountAlias(ACCOUNT_B, QStringLiteral(" Private savings "))); + QVERIFY(controller.setPrimaryAccount(ACCOUNT_B)); + QCOMPARE(controller.state().primaryAccountName, QStringLiteral("Private savings")); + QVERIFY(!controller.setAccountAlias(ACCOUNT_A, QString(41, QLatin1Char('x')))); + } + + WalletController reopened(provider, application); + QVERIFY(reopened.open()); + QCOMPARE(reopened.state().primaryAccountAddress, ACCOUNT_B); + QCOMPARE(reopened.state().primaryAccountName, QStringLiteral("Private savings")); + settings.clear(); } void LogosWalletProviderTest::fakeProviderImplementsConsumerContract() @@ -419,6 +536,65 @@ void LogosWalletProviderTest::controllerOwnsUiWalletFlow() settings.clear(); } +void LogosWalletProviderTest::controllerReportsCreationPersistenceAndRefreshFailures() +{ + const QString settingsApplication = QStringLiteral("WalletCreationFailureTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + WalletController controller(provider, settingsApplication); + const WalletUiState baseline = controller.state(); + const int baselineAccountCount = controller.accountModel()->count(); + QSignalSpy snapshotChanged(&controller, &WalletController::snapshotChanged); + + provider.createWalletResult.mnemonic = QStringLiteral("alpha beta gamma"); + provider.createWalletResult.failure = WalletFailure::SaveFailed; + provider.createWalletResult.snapshot.failure = WalletFailure::SaveFailed; + QCOMPARE(controller.createWallet(QStringLiteral("config"), QStringLiteral("storage"), + QStringLiteral("secret")), + QString()); + QCOMPARE(controller.state().isWalletOpen, baseline.isWalletOpen); + QCOMPARE(controller.state().walletExists, baseline.walletExists); + QCOMPARE(controller.state().syncStatus, baseline.syncStatus); + QCOMPARE(controller.state().syncError, baseline.syncError); + QCOMPARE(controller.accountModel()->count(), baselineAccountCount); + QCOMPARE(snapshotChanged.count(), 0); + + provider.createWalletResult.failure = WalletFailure::ReadFailed; + provider.createWalletResult.snapshot.failure = WalletFailure::ReadFailed; + QCOMPARE(controller.createWallet(QStringLiteral("config"), QStringLiteral("storage"), + QStringLiteral("secret")), + QStringLiteral("alpha beta gamma")); + QVERIFY(controller.state().isWalletOpen); + QVERIFY(controller.state().walletExists); + QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); + QCOMPARE(controller.state().syncError, QStringLiteral("read_failed")); + QCOMPARE(controller.accountModel()->count(), baselineAccountCount); + QCOMPARE(snapshotChanged.count(), 0); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.failure = WalletFailure::ReadFailed; + QCOMPARE(controller.createAccount(true), ACCOUNT_B); + QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); + QCOMPARE(controller.state().syncError, QStringLiteral("read_failed")); + QCOMPARE(controller.accountModel()->count(), baselineAccountCount); + QCOMPARE(snapshotChanged.count(), 0); + + provider.createAccountResult.snapshot = {}; + provider.createAccountResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true, QStringLiteral("ok"), EOA_OWNER, {} }, + { ACCOUNT_B, QStringLiteral("3"), true, QStringLiteral("ok"), EOA_OWNER, {} }, + }; + QCOMPARE(controller.createAccount(true), ACCOUNT_B); + QCOMPARE(controller.state().syncStatus, QStringLiteral("ready")); + QVERIFY(controller.state().syncError.isEmpty()); + QCOMPARE(controller.accountModel()->count(), 2); + QCOMPARE(snapshotChanged.count(), 1); + + settings.clear(); +} + void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() { const QString settingsApplication = QStringLiteral("WalletReachabilityTest"); diff --git a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h index c1927f43..13ca5d77 100644 --- a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h +++ b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h @@ -6,6 +6,8 @@ #include #include +#include + class LogosAPI; class FakeExecutionZone { @@ -48,6 +50,13 @@ class FakeExecutionZone { return openResult; } + void openAsync(const QString& config, + const QString& storage, + std::function callback) + { + callback(open(config, storage)); + } + QString create_new(const QString& config, const QString& storage, const QString& password) @@ -64,36 +73,69 @@ class FakeExecutionZone { return saveResult; } + void saveAsync(std::function callback) { callback(save()); } + 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; } + void get_last_synced_blockAsync(std::function callback) + { + callback(get_last_synced_block()); + } + void get_current_block_heightAsync(std::function callback) + { + callback(get_current_block_height()); + } int sync_to_block(quint64) { ++syncCalls; return syncResult; } + void sync_to_blockAsync(int blockId, std::function callback) + { + callback(sync_to_block(static_cast(blockId))); + } QString get_sequencer_addr() const { return sequencerAddress; } + void get_sequencer_addrAsync(std::function callback) + { + callback(get_sequencer_addr()); + } QVariantList list_accounts() { ++listCalls; return accounts; } + void list_accountsAsync(std::function callback) + { + callback(list_accounts()); + } QString get_account_public(const QString& accountId) { ++publicReadCalls; return publicAccounts.value(accountId); } + void get_account_publicAsync(const QString& accountId, + std::function callback) + { + callback(get_account_public(accountId)); + } QString get_balance(const QString& accountId, bool) const { return balances.value(accountId); } + void get_balanceAsync(const QString& accountId, + bool isPublic, + std::function callback) + { + callback(get_balance(accountId, isPublic)); + } QString send_generic_public_transaction( const QStringList& accountIds, diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml index 157a0bcd..3a07e347 100644 --- a/apps/shared/wallet/tests/qml/tst_WalletControl.qml +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -13,32 +13,62 @@ 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 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 (completeOpenImmediately) { + walletSyncStatus = "ready" + isWalletOpen = true + } 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 +76,17 @@ Item { disconnectCalls++ isWalletOpen = false } + + function setPrimaryAccount(address) { + primaryAccountCalls++ + primaryAccountAddress = address + return true + } + + function setAccountAlias(_address, _alias) { + aliasCalls++ + return true + } } } @@ -115,7 +156,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 +165,24 @@ Item { return { backend, model, control } } + function accountData(account) { + return { + name: account.name || "Account", + alias: account.alias || "", + address: account.address || "", + displayAddress: account.displayAddress || account.address || "", + balance: account.balance || "0", + isPublic: account.isPublic === true, + kind: account.kind || (account.isPublic === false ? "private" : "user"), + section: account.section || "accounts", + programName: account.programName || "", + accountType: account.accountType || "", + visibility: account.visibility || (account.isPublic === false ? "private" : "public"), + canBePrimary: account.canBePrimary === undefined ? true : account.canBePrimary, + isPrimary: account.isPrimary === true + } + } + function test_opensExistingWallet() { const fixture = createControl({ walletExists: true }, []) const connectButton = findChild(fixture.control, "walletConnectButton") @@ -133,6 +192,81 @@ Item { tryCompare(fixture.control, "connected", true) } + function test_disablesConnectWhileWalletIsOpening() { + const fixture = createControl({ + walletExists: true, + walletSyncStatus: "syncing" + }, []) + const connectButton = findChild(fixture.control, "walletConnectButton") + verify(!connectButton.enabled) + compare(connectButton.text, "Connecting…") + mouseClick(connectButton) + compare(fixture.backend.openCalls, 0) + } + + function test_showsAsyncOpenFailure() { + const fixture = createControl({ + walletExists: true, + completeOpenImmediately: false + }, []) + const connectButton = findChild(fixture.control, "walletConnectButton") + mouseClick(connectButton) + compare(fixture.backend.openCalls, 1) + verify(fixture.control.busy) + + fixture.backend.walletSyncStatus = "error" + fixture.backend.walletSyncError = "open_failed" + + const dialog = findChild(fixture.control, "walletMessageDialog") + tryCompare(dialog, "opened", true) + compare(fixture.control.busy, false) + compare(dialog.message, "Wallet could not be opened: open_failed") + } + + function test_showsAsyncMissingWallet() { + const fixture = createControl({ + walletExists: true, + completeOpenImmediately: false + }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + verify(fixture.control.busy) + + fixture.backend.walletExists = false + + const dialog = findChild(fixture.control, "walletMessageDialog") + tryCompare(dialog, "opened", true) + compare(fixture.control.busy, false) + compare(dialog.message, "Wallet could not be opened.") + } + + function test_showsStartupOpenFailure() { + const fixture = createControl({ + walletExists: true, + walletSyncStatus: "error", + walletSyncError: "open_failed" + }, []) + const dialog = findChild(fixture.control, "walletMessageDialog") + tryCompare(dialog, "opened", true) + compare(dialog.message, "Wallet could not be opened: open_failed") + } + + function test_cancellingWalletCreationDoesNotClaimSuccess() { + const fixture = createControl({ walletExists: false }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + const creation = findChild(fixture.control, "createWalletDialog") + tryCompare(creation, "opened", true) + + fixture.backend.walletSyncStatus = "error" + fixture.backend.walletSyncError = "wallet_unavailable" + const message = findChild(fixture.control, "walletMessageDialog") + tryCompare(message, "opened", true) + compare(message.message, "Wallet could not be opened: wallet_unavailable") + + creation.close() + wait(0) + compare(message.message, "Wallet could not be opened: wallet_unavailable") + } + function test_requiresSeedBackupAcknowledgement() { const fixture = createControl({ walletExists: false }, []) mouseClick(findChild(fixture.control, "walletConnectButton")) @@ -165,6 +299,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 +343,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 }) @@ -226,8 +396,20 @@ Item { function test_selectsAccount() { const fixture = createControl({ isWalletOpen: true }, [ - { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, - { name: "Two", address: "b".repeat(64), balance: "20", isPublic: false } + { + name: "One", + address: "a".repeat(64), + displayAddress: "base58-one", + balance: "10", + isPublic: true + }, + { + name: "Two", + address: "b".repeat(64), + displayAddress: "base58-two", + balance: "20", + isPublic: false + } ]) mouseClick(findChild(fixture.control, "walletAccountButton")) const accountsButton = findChild(fixture.control, "walletAccountsButton") @@ -240,7 +422,95 @@ Item { const secondAccount = accountList.itemAtIndex(1) secondAccount.clicked() tryCompare(fixture.control, "selectedIndex", 1) + compare(fixture.backend.primaryAccountAddress, "b".repeat(64)) compare(fixture.control.selectedAddress, "b".repeat(64)) + compare(fixture.control.selectedDisplayAddress, "base58-two") + } + + function test_accountNavigationKeepsOverviewInsidePopup() { + const assets = [] + for (let index = 0; index < 10; ++index) { + assets.push({ + name: "Token " + index, + balance: "100", + definitionId: "c".repeat(64), + displayDefinitionId: "base58-token-" + index, + status: "ready", + section: "assets" + }) + } + const fixture = createControl({ isWalletOpen: true, assets: assets }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const stack = findChild(fixture.control, "walletStack") + verify(stack, "Wallet stack exists") + verify(stack.clip, "Wallet pages are clipped to the popup") + mouseClick(findChild(fixture.control, "walletAccountsButton")) + tryCompare(stack, "busy", false) + compare(stack.depth, 2) + + mouseClick(findChild(fixture.control, "walletAccountsBackButton")) + tryCompare(stack, "busy", false) + compare(stack.depth, 1) + compare(stack.currentItem.x, 0) + const overviewContent = findChild(fixture.control, "walletOverviewContent") + verify(overviewContent, "Wallet overview content exists") + compare(overviewContent.mapToItem(stack, 0, 0).x, 0) + } + + function test_programRecordCannotBecomePrimary() { + const userAddress = "a".repeat(64) + const programAddress = "c".repeat(64) + const fixture = createControl({ + isWalletOpen: true, + primaryAccountAddress: userAddress, + primaryAccountName: "Trading" + }, [ + { + name: "Trading", + address: userAddress, + balance: "10", + isPublic: true, + kind: "user", + isPrimary: true + }, + { + name: "Token definition", + address: programAddress, + balance: "0", + isPublic: true, + kind: "token_definition", + section: "advanced", + programName: "Token", + accountType: "TokenDefinition", + canBePrimary: false + } + ]) + compare(fixture.control.selectedAddress, userAddress) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + mouseClick(findChild(fixture.control, "walletAdvancedAccountsButton")) + const list = findChild(fixture.control, "walletAccountList") + tryVerify(function() { return list.itemAtIndex(1) !== null }) + list.itemAtIndex(1).clicked() + compare(fixture.backend.primaryAccountCalls, 0) + compare(fixture.control.selectedAddress, userAddress) + } + + function test_onlyProgramRecordsLeavesPrimaryEmpty() { + const fixture = createControl({ isWalletOpen: true }, [{ + name: "Token definition", + address: "c".repeat(64), + balance: "0", + isPublic: true, + kind: "token_definition", + section: "advanced", + canBePrimary: false + }]) + compare(fixture.control.selectedIndex, -1) + compare(fixture.control.selectedAddress, "") + compare(fixture.control.primaryName, "") } function test_createsAccount() { @@ -262,6 +532,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 +611,12 @@ Item { const model = createTemporaryObject(modelComponent, root) verify(backend && model, "Wallet fixture exists") for (let index = 0; index < 10; ++index) { - model.append({ + model.append(accountData({ name: "Account " + index, address: String(index).repeat(64), balance: String(index), isPublic: true - }) + })) } const window = createTemporaryObject(compactWindowComponent, root) diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h index d5dea0ad..477b7251 100644 --- a/apps/shared/wallet/tests/support/FakeWalletProvider.h +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "WalletProvider.h" class FakeWalletProvider final : public WalletProvider { @@ -9,6 +11,7 @@ class FakeWalletProvider final : public WalletProvider { WalletSnapshot snapshotResult; WalletAccountCreation createAccountResult; WalletAccountRead readResult; + QVector readResults; WalletSubmission submissionResult; int connectCalls = 0; @@ -31,6 +34,13 @@ class FakeWalletProvider final : public WalletProvider { return connectResult; } + void connectAsync(const WalletPaths& paths, SessionCallback callback) override + { + ++connectCalls; + lastPaths = paths; + callback(connectResult); + } + WalletCreation createWallet(const WalletPaths& paths, const QString&) override { @@ -46,6 +56,13 @@ class FakeWalletProvider final : public WalletProvider { return snapshotResult; } + void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override + { + ++snapshotCalls; + lastForceRefresh = forceRefresh; + callback(snapshotResult); + } + void clearSnapshot() override { ++clearCalls; } WalletAccountCreation createAccount(bool isPublic) override @@ -63,6 +80,21 @@ class FakeWalletProvider final : public WalletProvider { return result; } + void readPublicAccountsAsync(const QStringList& accountIds, + AccountReadsCallback callback) override + { + QVector results = readResults; + if (results.isEmpty()) { + results.reserve(accountIds.size()); + for (const QString& accountId : accountIds) { + WalletAccountRead result = readResult; + result.accountId = accountId; + results.append(std::move(result)); + } + } + callback(std::move(results)); + } + WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) override { diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..e762aca2 --- /dev/null +++ b/flake.lock @@ -0,0 +1,44 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "ref": "v0.23.4", + "repo": "crane", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1782841183, + "narHash": "sha256-Ndt/5R7UN4rBdhFR1lxHZZZ42cD6vGlnuxC2VvvsKE4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c9bfd86ed684d27e63b0ff9ebb18699f84f27a3b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11-small", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..80575fb7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,45 @@ +{ + description = "LEZ program client libraries"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11-small"; + crane.url = "github:ipetkov/crane/v0.23.4"; + }; + + outputs = { nixpkgs, crane, ... }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in { + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + craneLib = crane.mkLib pkgs; + src = craneLib.cleanCargoSource ./.; + commonArgs = { + inherit src; + pname = "wallet-idl-decoder"; + version = "0.1.0"; + strictDeps = true; + cargoExtraArgs = "-p wallet-idl-decoder"; + }; + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + decoder = craneLib.buildPackage (commonArgs // { + inherit cargoArtifacts; + doCheck = false; + postInstall = '' + install -Dm644 ${./tools/wallet-idl-decoder/include/wallet_idl_decoder.h} \ + $out/include/wallet_idl_decoder.h + ''; + }); + in { + default = decoder; + wallet_idl_decoder = decoder; + }); + }; +} diff --git a/tools/wallet-idl-decoder/Cargo.toml b/tools/wallet-idl-decoder/Cargo.toml new file mode 100644 index 00000000..e407f8b1 --- /dev/null +++ b/tools/wallet-idl-decoder/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "wallet-idl-decoder" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[lib] +name = "wallet_idl_decoder" +crate-type = ["cdylib", "rlib"] + +[dependencies] +base58 = "0.2" +hex = "0.4" +serde = { workspace = true } +serde_json = { workspace = true } +spel-framework-core = { git = "https://github.com/logos-co/spel.git", tag = "v0.6.0" } diff --git a/tools/wallet-idl-decoder/include/wallet_idl_decoder.h b/tools/wallet-idl-decoder/include/wallet_idl_decoder.h new file mode 100644 index 00000000..fd724f37 --- /dev/null +++ b/tools/wallet-idl-decoder/include/wallet_idl_decoder.h @@ -0,0 +1,15 @@ +#ifndef WALLET_IDL_DECODER_H +#define WALLET_IDL_DECODER_H + +#ifdef __cplusplus +extern "C" { +#endif + +char *wallet_idl_decode_accounts(const char *request_json); +void wallet_idl_decoder_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/tools/wallet-idl-decoder/src/lib.rs b/tools/wallet-idl-decoder/src/lib.rs new file mode 100644 index 00000000..db7a2010 --- /dev/null +++ b/tools/wallet-idl-decoder/src/lib.rs @@ -0,0 +1,278 @@ +use std::{ + collections::BTreeMap, + ffi::{CStr, CString}, + os::raw::c_char, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use base58::FromBase58; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use spel_framework_core::{decode::decode_account_data_try_all, idl::SpelIdl}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DecodeRequest { + idl: SpelIdl, + accounts: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AccountInput { + id: String, + data_hex: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DecodeResponse { + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'static str>, + accounts: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AccountOutput { + id: String, + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + type_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + account_ids: BTreeMap, +} + +fn decode_request(request: DecodeRequest) -> DecodeResponse { + let accounts = request + .accounts + .into_iter() + .map(|account| decode_account(account, &request.idl)) + .collect(); + DecodeResponse { + status: "ok", + error: None, + accounts, + } +} + +fn decode_account(account: AccountInput, idl: &SpelIdl) -> AccountOutput { + let Ok(data) = hex::decode(&account.data_hex) else { + return AccountOutput { + id: account.id, + status: "invalid_data", + type_name: None, + value: None, + account_ids: BTreeMap::new(), + }; + }; + let Some((type_name, value)) = decode_account_data_try_all(&data, idl) else { + return AccountOutput { + id: account.id, + status: "unknown_type", + type_name: None, + value: None, + account_ids: BTreeMap::new(), + }; + }; + let mut account_ids = BTreeMap::new(); + collect_account_ids(&value, &mut account_ids); + AccountOutput { + id: account.id, + status: "decoded", + type_name: Some(type_name), + value: Some(value), + account_ids, + } +} + +fn collect_account_ids(value: &Value, output: &mut BTreeMap) { + match value { + Value::String(encoded) => { + let Some(base58) = encoded.strip_prefix("Public/") else { + return; + }; + if let Ok(bytes) = base58.from_base58() { + if bytes.len() == 32 { + output.insert(encoded.clone(), hex::encode(bytes)); + } + } + } + Value::Array(values) => { + for nested in values { + collect_account_ids(nested, output); + } + } + Value::Object(values) => { + for nested in values.values() { + collect_account_ids(nested, output); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn error_response(error: &'static str) -> DecodeResponse { + DecodeResponse { + status: "error", + error: Some(error), + accounts: Vec::new(), + } +} + +fn response_pointer(response: &DecodeResponse) -> *mut c_char { + let json = serde_json::to_string(response).unwrap_or_else(|_| { + String::from(r#"{"status":"error","error":"serialization_failed","accounts":[]}"#) + }); + CString::new(json).map_or(std::ptr::null_mut(), CString::into_raw) +} + +#[expect( + unsafe_code, + reason = "C ABI input requires reading a caller-owned C string" +)] +fn decode_pointer(request_json: *const c_char) -> DecodeResponse { + if request_json.is_null() { + return error_response("null_request"); + } + let bytes = unsafe { + // SAFETY: Caller owns a non-null NUL-terminated C string for this call. + CStr::from_ptr(request_json) + }; + let Ok(json) = bytes.to_str() else { + return error_response("invalid_utf8"); + }; + match serde_json::from_str::(json) { + Ok(request) => decode_request(request), + Err(_) => error_response("invalid_request"), + } +} + +/// Decodes a JSON batch request using its embedded SPEL IDL. +/// +/// Returns a library-owned JSON C string. Release it with +/// [`wallet_idl_decoder_free`]. +#[no_mangle] +#[expect(unsafe_code, reason = "C ABI requires a stable exported symbol")] +pub extern "C" fn wallet_idl_decode_accounts(request_json: *const c_char) -> *mut c_char { + let response = catch_unwind(AssertUnwindSafe(|| decode_pointer(request_json))) + .unwrap_or_else(|_| error_response("panic")); + response_pointer(&response) +} + +/// Frees a response allocated by [`wallet_idl_decode_accounts`]. +/// +/// # Safety +/// +/// `value` must be null or a pointer returned by this library that has not +/// already been freed. +#[no_mangle] +#[expect(unsafe_code, reason = "C ABI deallocator reconstructs its CString")] +pub unsafe extern "C" fn wallet_idl_decoder_free(value: *mut c_char) { + if !value.is_null() { + unsafe { + // SAFETY: Pointer must come from CString::into_raw in this library and be freed once. + drop(CString::from_raw(value)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn token_idl() -> SpelIdl { + match serde_json::from_str(include_str!("../../../artifacts/token-idl.json")) { + Ok(idl) => idl, + Err(error) => panic!("committed token IDL should parse: {error}"), + } + } + + #[test] + fn decodes_fungible_definition() { + let request = DecodeRequest { + idl: token_idl(), + accounts: vec![AccountInput { + id: "definition".to_owned(), + data_hex: concat!( + "00", // Fungible variant + "04000000", + "54455354", // TEST + "0a000000000000000000000000000000", // supply 10 + "00", // metadata_id None + "00" // authority None + ) + .to_owned(), + }], + }; + let response = decode_request(request); + let Some(account) = response.accounts.first() else { + panic!("decoder should return one account"); + }; + assert_eq!(account.status, "decoded"); + assert_eq!(account.type_name.as_deref(), Some("TokenDefinition")); + assert_eq!( + account + .value + .as_ref() + .and_then(|value| value.get("Fungible")) + .and_then(|value| value.get("name")) + .and_then(Value::as_str), + Some("TEST") + ); + } + + #[test] + fn maps_decoded_public_ids_to_hex() { + let request = DecodeRequest { + idl: token_idl(), + accounts: vec![AccountInput { + id: "holding".to_owned(), + data_hex: format!("00{}19000000000000000000000000000000", "01".repeat(32)), + }], + }; + let response = decode_request(request); + let Some(account) = response.accounts.first() else { + panic!("decoder should return one account"); + }; + let expected = "01".repeat(32); + assert_eq!(account.status, "decoded"); + assert_eq!(account.type_name.as_deref(), Some("TokenHolding")); + assert_eq!( + account.account_ids.values().next().map(String::as_str), + Some(expected.as_str()) + ); + } + + #[test] + fn rejects_invalid_hex_per_account() { + let response = decode_request(DecodeRequest { + idl: token_idl(), + accounts: vec![AccountInput { + id: "broken".to_owned(), + data_hex: "xyz".to_owned(), + }], + }); + assert_eq!(response.status, "ok"); + assert_eq!( + response.accounts.first().map(|account| account.status), + Some("invalid_data") + ); + } + + #[test] + #[expect(unsafe_code, reason = "test verifies the exported C allocator pair")] + fn ffi_allocates_json_and_accepts_its_pointer_on_free() { + let response = wallet_idl_decode_accounts(std::ptr::null()); + assert!(!response.is_null()); + let json = match unsafe { CStr::from_ptr(response) }.to_str() { + Ok(json) => json, + Err(error) => panic!("response should be UTF-8 JSON: {error}"), + }; + assert!(json.contains("null_request")); + unsafe { wallet_idl_decoder_free(response) }; + } +} From 9d4af6a5f75d2bef736dbadf070de245803f84a7 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 20:26:29 -0300 Subject: [PATCH 07/14] perf(wallet): avoid redundant async snapshot saves --- .../shared/wallet/src/LogosWalletProvider.cpp | 17 ++++--------- .../tests/cpp/LogosWalletProviderTest.cpp | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp index f23321f1..6015c61f 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.cpp +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -609,18 +609,11 @@ void LogosWalletProvider::loadSnapshotAsync(quint64 generation, SnapshotCallback state->snapshot.publicAccountReads.append( state->publicReads.at(index)); } - m_impl->logos->logos_execution_zone.saveAsync( - [this, generation, state](int result) mutable { - if (generation != m_generation) - return; - if (result != WALLET_FFI_SUCCESS) - state->snapshot.failure = WalletFailure::SaveFailed; - if (state->snapshot.ok()) { - m_snapshot = state->snapshot; - m_snapshotReady = true; - } - state->callback(std::move(state->snapshot)); - }); + if (state->snapshot.ok()) { + m_snapshot = state->snapshot; + m_snapshotReady = true; + } + state->callback(std::move(state->snapshot)); }; if (entries.isEmpty()) { diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index 3be65657..c1d9e474 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -52,6 +52,7 @@ private slots: void adoptsOpenWalletAndCachesSnapshots(); void opensConfiguredWalletWhenNoSharedSessionExists(); void opensAndReadsAsynchronously(); + void avoidsSavingAfterUnchangedAsynchronousSnapshots(); void createsAndPersistsWallet(); void validatesCompletePublicAccountPayloads(); void fallsBackToBalanceWhenPublicReadFails(); @@ -182,6 +183,29 @@ void LogosWalletProviderTest::opensAndReadsAsynchronously() QVERIFY(batchRead); } +void LogosWalletProviderTest::avoidsSavingAfterUnchangedAsynchronousSnapshots() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.currentBlockHeight = 12; + modules.logos_execution_zone.lastSyncedBlock = 12; + LogosWalletProvider provider(&modules); + + bool connected = false; + provider.connectAsync({}, [&connected](WalletSession session) { + connected = session.ok(); + }); + QVERIFY(connected); + QCOMPARE(modules.logos_execution_zone.saveCalls, 0); + + bool refreshed = false; + provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) { + refreshed = snapshot.ok(); + }); + QVERIFY(refreshed); + QCOMPARE(modules.logos_execution_zone.saveCalls, 0); +} + void LogosWalletProviderTest::createsAndPersistsWallet() { QTemporaryDir directory; From addccf3850aa15b76e009cb86da20d7275c525d7 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 20:29:34 -0300 Subject: [PATCH 08/14] perf(wallet): skip redundant wallet account probes --- .../shared/wallet/src/LogosWalletProvider.cpp | 15 +++--------- .../tests/cpp/LogosWalletProviderTest.cpp | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp index 6015c61f..3f4722aa 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.cpp +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -229,15 +229,7 @@ void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback finishOpen(true, WalletFailure::None); return; } - m_impl->logos->logos_execution_zone.list_accountsAsync( - [this, generation, finishOpen, openStored](QVariantList accounts) mutable { - if (generation != m_generation) - return; - if (!accounts.isEmpty()) - finishOpen(true, WalletFailure::None); - else - openStored(); - }); + openStored(); }); } @@ -468,9 +460,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() diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index c1d9e474..b9138c0f 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -51,6 +51,7 @@ class LogosWalletProviderTest : public QObject { private slots: void adoptsOpenWalletAndCachesSnapshots(); void opensConfiguredWalletWhenNoSharedSessionExists(); + void opensStoredWalletAsynchronouslyWithoutAccountProbe(); void opensAndReadsAsynchronously(); void avoidsSavingAfterUnchangedAsynchronousSnapshots(); void createsAndPersistsWallet(); @@ -144,6 +145,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); @@ -151,6 +153,28 @@ 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; From fc1658635be61941a3778ee9187cd9c37e505918 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 20:34:28 -0300 Subject: [PATCH 09/14] perf(wallet): reuse created account snapshot reads --- apps/shared/wallet/src/LogosWalletProvider.cpp | 11 +++++++++-- .../wallet/tests/cpp/LogosWalletProviderTest.cpp | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp index 3f4722aa..4a001894 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.cpp +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -329,9 +329,16 @@ WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic) return creation; } - if (isPublic) - creation.publicAccount = readPublicAccount(creation.accountId); creation.snapshot = snapshot(true); + if (isPublic) { + for (const WalletAccountRead& read : creation.snapshot.publicAccountReads) { + if (read.accountId == creation.accountId) { + creation.publicAccount = read; + return creation; + } + } + creation.publicAccount = readPublicAccount(creation.accountId); + } return creation; } diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index b9138c0f..75ccfb74 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -319,12 +319,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); From 6ccd37b231c55b49bb4b3966e81dc8ff6cb3437d Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 20:38:49 -0300 Subject: [PATCH 10/14] perf(wallet): batch account presentation updates --- apps/shared/wallet/src/WalletAccountModel.cpp | 34 ++++++++++++++++--- .../tests/cpp/LogosWalletProviderTest.cpp | 31 ++++++++++++----- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp index 615f4b75..f9b52626 100644 --- a/apps/shared/wallet/src/WalletAccountModel.cpp +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -130,11 +130,21 @@ void WalletAccountModel::replaceAccounts(const QVector& accounts, void WalletAccountModel::applyPresentations( const QVector& presentations) { + QHash rowsByAddress; + rowsByAddress.reserve(m_accounts.size()); + for (int row = 0; row < m_accounts.size(); ++row) { + const QString& address = m_accounts.at(row).address; + if (!rowsByAddress.contains(address)) + rowsByAddress.insert(address, row); + } + + int firstChanged = m_accounts.size(); + int lastChanged = -1; for (const WalletAccountPresentation& presentation : presentations) { - const int row = indexOf(presentation.address); - if (row < 0) + const auto row = rowsByAddress.constFind(presentation.address); + if (row == rowsByAddress.cend()) continue; - Entry& entry = m_accounts[row]; + Entry& entry = m_accounts[row.value()]; if (!presentation.kind.isEmpty()) entry.kind = presentation.kind; entry.programName = presentation.programName; @@ -147,9 +157,23 @@ void WalletAccountModel::applyPresentations( if (!entry.canBePrimary) entry.isPrimary = false; updateEntryName(entry); - const QModelIndex changed = index(row); - emit dataChanged(changed, changed); + if (row.value() < firstChanged) + firstChanged = row.value(); + if (row.value() > lastChanged) + lastChanged = row.value(); } + if (lastChanged < 0) + return; + emit dataChanged(index(firstChanged), index(lastChanged), { + NameRole, + KindRole, + SectionRole, + ProgramNameRole, + AccountTypeRole, + CanBePrimaryRole, + IsPrimaryRole, + DefinitionIdRole, + }); } void WalletAccountModel::setAlias(const QString& address, const QString& alias) diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index 75ccfb74..b9cf4176 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -458,15 +458,28 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() QStringLiteral("program")); QVERIFY(!model.data(model.index(2), WalletAccountModel::CanBePrimaryRole).toBool()); - model.applyPresentations({ { - ACCOUNT_C, - QStringLiteral("token_holding"), - QStringLiteral("TEST holding"), - QStringLiteral("Token"), - QStringLiteral("TokenHolding"), - ACCOUNT_A, - true, - } }); + QSignalSpy presentationsChanged(&model, &QAbstractItemModel::dataChanged); + model.applyPresentations({ + { + ACCOUNT_A, + QStringLiteral("program"), + {}, + QStringLiteral("System"), + QStringLiteral("UserAccount"), + {}, + false, + }, + { + ACCOUNT_C, + QStringLiteral("token_holding"), + QStringLiteral("TEST holding"), + QStringLiteral("Token"), + QStringLiteral("TokenHolding"), + ACCOUNT_A, + true, + }, + }); + QCOMPARE(presentationsChanged.count(), 1); QCOMPARE(model.data(model.index(2), WalletAccountModel::SectionRole).toString(), QStringLiteral("hidden")); QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), From b71be34edfcbec612dc6988ce5d533eb76e41fe4 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 21:39:09 -0300 Subject: [PATCH 11/14] perf(amm): cache verified token definitions --- apps/amm/CMakeLists.txt | 43 ++++ apps/amm/src/AmmUiBackend.cpp | 71 ++++- apps/amm/src/AmmUiBackend.h | 14 +- apps/amm/src/TokenDefinitionCache.cpp | 104 ++++++++ apps/amm/src/TokenDefinitionCache.h | 53 ++++ .../cpp/AmmUiBackendDefinitionCacheTest.cpp | 242 ++++++++++++++++++ .../tests/cpp/TokenDefinitionCacheTest.cpp | 185 +++++++++++++ .../wallet/tests/support/FakeWalletProvider.h | 47 +++- 8 files changed, 744 insertions(+), 15 deletions(-) create mode 100644 apps/amm/src/TokenDefinitionCache.cpp create mode 100644 apps/amm/src/TokenDefinitionCache.h create mode 100644 apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp create mode 100644 apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index d96a7f19..670ed7b3 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -33,6 +33,8 @@ logos_module( src/AmmUiBackend.cpp src/ActiveNetwork.h src/ActiveNetwork.cpp + src/TokenDefinitionCache.h + src/TokenDefinitionCache.cpp src/WalletIdlDecoder.h src/WalletIdlDecoder.cpp FIND_PACKAGES @@ -74,4 +76,45 @@ if(BUILD_TESTING) target_include_directories(amm_active_network_test PRIVATE src) target_link_libraries(amm_active_network_test PRIVATE Qt6::Core Qt6::Test) add_test(NAME amm_active_network COMMAND amm_active_network_test) + + add_executable(amm_token_definition_cache_test + tests/cpp/TokenDefinitionCacheTest.cpp + src/TokenDefinitionCache.h + src/TokenDefinitionCache.cpp + ) + set_target_properties(amm_token_definition_cache_test PROPERTIES AUTOMOC ON) + target_compile_features(amm_token_definition_cache_test PRIVATE cxx_std_17) + target_include_directories(amm_token_definition_cache_test PRIVATE + src + "${LOGOS_WALLET_SOURCE_DIR}/src" + "${LOGOS_WALLET_SOURCE_DIR}/tests/support" + ) + target_link_libraries(amm_token_definition_cache_test PRIVATE Qt6::Core Qt6::Test) + add_test(NAME amm_token_definition_cache COMMAND amm_token_definition_cache_test) + + add_executable(amm_backend_definition_cache_test + tests/cpp/AmmUiBackendDefinitionCacheTest.cpp + ) + add_dependencies(amm_backend_definition_cache_test amm_ui_module_plugin) + set_target_properties(amm_backend_definition_cache_test PROPERTIES AUTOMOC ON) + target_compile_features(amm_backend_definition_cache_test PRIVATE cxx_std_17) + target_include_directories(amm_backend_definition_cache_test PRIVATE + src + "${CMAKE_CURRENT_BINARY_DIR}" + "${LOGOS_WALLET_SOURCE_DIR}/src" + "${LOGOS_WALLET_SOURCE_DIR}/tests/support" + ) + target_link_libraries(amm_backend_definition_cache_test PRIVATE + Qt6::Core + Qt6::Network + Qt6::RemoteObjects + Qt6::Test + amm_ui_module_plugin + ) + if(UNIX AND NOT APPLE) + target_link_options(amm_backend_definition_cache_test PRIVATE + "-Wl,--allow-shlib-undefined" + ) + endif() + add_test(NAME amm_backend_definition_cache COMMAND amm_backend_definition_cache_test) endif() diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 106d68fe..53ed616a 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -109,12 +109,33 @@ WalletAccountRead accountRead(const WalletAccount& account) AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), - m_wallet(std::make_unique(m_logosAPI)), + m_ownedWallet(std::make_unique(m_logosAPI)), + m_wallet(m_ownedWallet.get()), + m_definitionCache(*m_wallet), m_walletController(std::make_unique( *m_wallet, QStringLiteral("AmmUI"))), m_networkManager(new QNetworkAccessManager(this)), m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) +{ + initialize(); +} + +AmmUiBackend::AmmUiBackend(WalletProvider& wallet, QObject* parent) + : AmmUiBackendSimpleSource(parent), + m_logosAPI(nullptr), + m_wallet(&wallet), + m_definitionCache(*m_wallet), + m_walletController(std::make_unique( + *m_wallet, QStringLiteral("AmmUI"))), + m_networkManager(new QNetworkAccessManager(this)), + m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), + m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) +{ + initialize(); +} + +void AmmUiBackend::initialize() { setAssets({}); setAssetStatus(QStringLiteral("idle")); @@ -198,6 +219,10 @@ bool AmmUiBackend::setPrimaryAccount(QString accountId) void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); + if (state.syncStatus == QStringLiteral("opening") + || state.syncStatus == QStringLiteral("syncing")) { + m_definitionCache.cancelPending(); + } const QString previousAddress = sequencerAddr(); const bool wasReachable = sequencerReachable(); setIsWalletOpen(state.isWalletOpen); @@ -274,41 +299,71 @@ void AmmUiBackend::refreshPortfolio() { const quint64 generation = ++m_portfolioGeneration; if (!m_walletController->state().isWalletOpen) { + m_definitionCache.cancelPending(); setAssets({}); setAssetStatus(QStringLiteral("idle")); setAssetError({}); return; } if (m_network.status() != QStringLiteral("ready")) { + invalidateDefinitionCache(); setAssets({}); setAssetStatus(QStringLiteral("blocked")); setAssetError(m_network.status()); return; } if (m_tokenIdl.isEmpty()) { + invalidateDefinitionCache(); setAssetStatus(QStringLiteral("error")); setAssetError(QStringLiteral("token_idl_missing")); return; } + const TokenDefinitionCacheKey key = definitionCacheKey(m_network.snapshot()); setAssetStatus(QStringLiteral("loading")); setAssetError({}); - m_wallet->readPublicAccountsAsync( - m_network.snapshot().tokenIds, - [this, generation](QVector reads) { - applyDefinitions(generation, reads); + if (m_appliedDefinitionKey && *m_appliedDefinitionKey == key + && m_definitionCache.contains(key)) { + applyWalletPortfolio(generation); + return; + } + m_definitionCache.read( + key, + [this, generation, key](QVector reads) { + applyDefinitions(generation, key, reads); }); } +TokenDefinitionCacheKey AmmUiBackend::definitionCacheKey( + const ActiveNetworkSnapshot& network) const +{ + return { + network.id, + network.fingerprint, + sequencerAddr(), + network.tokenIds, + }; +} + +void AmmUiBackend::invalidateDefinitionCache() +{ + m_definitionCache.clear(); + m_appliedDefinitionKey.reset(); +} + void AmmUiBackend::applyDefinitions( quint64 generation, + const TokenDefinitionCacheKey& key, const QVector& reads) { if (generation != m_portfolioGeneration) return; const ActiveNetworkSnapshot network = m_network.snapshot(); + if (!(key == definitionCacheKey(network))) + return; const WalletDecodeResult decoded = WalletIdlDecoder::decode(m_tokenIdl, reads); if (!decoded.ok() || reads.size() != network.tokenIds.size() || decoded.accounts.size() != reads.size()) { + invalidateDefinitionCache(); setAssetStatus(QStringLiteral("error")); setAssetError(decoded.error.isEmpty() ? QStringLiteral("definition_decode_failed") @@ -338,6 +393,7 @@ void AmmUiBackend::applyDefinitions( if (m_tokenProgramId.isEmpty()) m_tokenProgramId = read.programOwner; else if (m_tokenProgramId != read.programOwner) { + invalidateDefinitionCache(); setAssets({}); setAssetStatus(QStringLiteral("error")); setAssetError(QStringLiteral("token_program_mismatch")); @@ -349,6 +405,7 @@ void AmmUiBackend::applyDefinitions( m_tokens.append(std::move(token)); } if (m_tokenProgramId.isEmpty()) { + invalidateDefinitionCache(); setAssets({}); setAssetStatus(QStringLiteral("error")); setAssetError(QStringLiteral("definitions_unavailable")); @@ -356,6 +413,10 @@ void AmmUiBackend::applyDefinitions( } m_idlRegistry.registerProgram( m_tokenProgramId, QStringLiteral("Token"), m_tokenIdl); + if (unavailable > 0) + invalidateDefinitionCache(); + else + m_appliedDefinitionKey = key; setAssetError(unavailable > 0 ? QStringLiteral("some_definitions_unavailable") : QString()); diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 9b6d84d8..32daa4ce 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -2,6 +2,7 @@ #define AMM_UI_BACKEND_H #include +#include #include #include @@ -10,6 +11,7 @@ #include "rep_AmmUiBackend_source.h" #include "ActiveNetwork.h" +#include "TokenDefinitionCache.h" #include "WalletAccountModel.h" #include "WalletIdlDecoder.h" @@ -24,6 +26,8 @@ class AmmUiBackend : public AmmUiBackendSimpleSource { public: explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); + // The injected provider must outlive the backend. + explicit AmmUiBackend(WalletProvider& wallet, QObject* parent = nullptr); ~AmmUiBackend() override; WalletAccountModel* accountModel() const; @@ -51,14 +55,21 @@ public slots: void syncWalletState(); void publishNetworkState(); + void initialize(); void probeNetworkIdentity(); void refreshPortfolio(); + TokenDefinitionCacheKey definitionCacheKey( + const ActiveNetworkSnapshot& network) const; + void invalidateDefinitionCache(); void applyDefinitions(quint64 generation, + const TokenDefinitionCacheKey& key, const QVector& reads); void applyWalletPortfolio(quint64 generation); LogosAPI* m_logosAPI; - std::unique_ptr m_wallet; + std::unique_ptr m_ownedWallet; + WalletProvider* m_wallet; + TokenDefinitionCache m_definitionCache; std::unique_ptr m_walletController; QNetworkAccessManager* m_networkManager; ActiveNetwork m_network; @@ -67,6 +78,7 @@ public slots: WalletIdlRegistry m_idlRegistry; QVector m_tokens; QString m_tokenProgramId; + std::optional m_appliedDefinitionKey; bool m_identityProbeInFlight = false; quint64 m_portfolioGeneration = 0; }; diff --git a/apps/amm/src/TokenDefinitionCache.cpp b/apps/amm/src/TokenDefinitionCache.cpp new file mode 100644 index 00000000..c1b424f1 --- /dev/null +++ b/apps/amm/src/TokenDefinitionCache.cpp @@ -0,0 +1,104 @@ +#include "TokenDefinitionCache.h" + +#include + +bool TokenDefinitionCacheKey::isReusable() const +{ + return !networkId.isEmpty() + && !networkFingerprint.isEmpty() + && !sequencerAddress.isEmpty() + && !tokenIds.isEmpty(); +} + +bool TokenDefinitionCacheKey::operator==(const TokenDefinitionCacheKey& other) const +{ + return networkId == other.networkId + && networkFingerprint == other.networkFingerprint + && sequencerAddress == other.sequencerAddress + && tokenIds == other.tokenIds; +} + +TokenDefinitionCache::TokenDefinitionCache(WalletProvider& provider) + : m_provider(provider), + m_state(std::make_shared()) +{ +} + +TokenDefinitionCache::~TokenDefinitionCache() +{ + clear(); +} + +void TokenDefinitionCache::read(const TokenDefinitionCacheKey& key, Callback callback) +{ + if (contains(key)) { + callback(m_state->cachedReads); + return; + } + if (m_state->inFlight && m_state->inFlight->key == key) { + m_state->inFlight->callbacks.append(std::move(callback)); + return; + } + cancelPending(); + + const auto request = std::make_shared(); + request->key = key; + request->callbacks.append(std::move(callback)); + m_state->inFlight = request; + const std::weak_ptr state = m_state; + m_provider.readPublicAccountsAsync( + key.tokenIds, + [state, request](QVector reads) mutable { + const std::shared_ptr lockedState = state.lock(); + if (!lockedState || request->cancelled) + return; + if (lockedState->inFlight == request) { + lockedState->inFlight.reset(); + if (TokenDefinitionCache::isComplete(request->key, reads)) { + lockedState->cachedKey = request->key; + lockedState->cachedReads = reads; + } + } + + QVector callbacks = std::move(request->callbacks); + for (Callback& callback : callbacks) + callback(reads); + }); +} + +bool TokenDefinitionCache::contains(const TokenDefinitionCacheKey& key) const +{ + return m_state->cachedKey && *m_state->cachedKey == key; +} + +void TokenDefinitionCache::cancelPending() +{ + if (m_state->inFlight) { + m_state->inFlight->cancelled = true; + m_state->inFlight->callbacks.clear(); + } + m_state->inFlight.reset(); +} + +void TokenDefinitionCache::clear() +{ + m_state->cachedKey.reset(); + m_state->cachedReads.clear(); + cancelPending(); +} + +bool TokenDefinitionCache::isComplete( + const TokenDefinitionCacheKey& key, + const QVector& reads) +{ + if (!key.isReusable() || reads.size() != key.tokenIds.size()) + return false; + for (qsizetype index = 0; index < reads.size(); ++index) { + const WalletAccountRead& read = reads.at(index); + if (!read.ok() || read.accountId != key.tokenIds.at(index) + || read.programOwner.isEmpty() || read.dataHex.isEmpty()) { + return false; + } + } + return true; +} diff --git a/apps/amm/src/TokenDefinitionCache.h b/apps/amm/src/TokenDefinitionCache.h new file mode 100644 index 00000000..3cdf31b5 --- /dev/null +++ b/apps/amm/src/TokenDefinitionCache.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "WalletProvider.h" + +struct TokenDefinitionCacheKey { + QString networkId; + QString networkFingerprint; + QString sequencerAddress; + QStringList tokenIds; + + bool isReusable() const; + bool operator==(const TokenDefinitionCacheKey& other) const; +}; + +class TokenDefinitionCache final { +public: + using Callback = std::function)>; + + explicit TokenDefinitionCache(WalletProvider& provider); + ~TokenDefinitionCache(); + + void read(const TokenDefinitionCacheKey& key, Callback callback); + bool contains(const TokenDefinitionCacheKey& key) const; + void cancelPending(); + void clear(); + +private: + struct InFlight { + TokenDefinitionCacheKey key; + QVector callbacks; + bool cancelled = false; + }; + + struct State { + std::optional cachedKey; + QVector cachedReads; + std::shared_ptr inFlight; + }; + + static bool isComplete(const TokenDefinitionCacheKey& key, + const QVector& reads); + + WalletProvider& m_provider; + std::shared_ptr m_state; +}; diff --git a/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp b/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp new file mode 100644 index 00000000..28e1bcbb --- /dev/null +++ b/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp @@ -0,0 +1,242 @@ +#include "AmmUiBackend.h" +#include "FakeWalletProvider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +class ScopedEnvironment final { +public: + ScopedEnvironment(QByteArray name, QByteArray value) + : m_name(std::move(name)), + m_hadValue(qEnvironmentVariableIsSet(m_name.constData())), + m_previous(qgetenv(m_name.constData())) + { + qputenv(m_name.constData(), value); + } + + ~ScopedEnvironment() + { + if (m_hadValue) + qputenv(m_name.constData(), m_previous); + else + qunsetenv(m_name.constData()); + } + +private: + QByteArray m_name; + bool m_hadValue; + QByteArray m_previous; +}; + +class LocalRpcServer final { +public: + explicit LocalRpcServer(QString channelId) + : m_channelId(std::move(channelId)) + { + QObject::connect(&m_server, &QTcpServer::newConnection, [&]() { + while (m_server.hasPendingConnections()) { + QTcpSocket* socket = m_server.nextPendingConnection(); + QObject::connect(socket, &QTcpSocket::readyRead, socket, + [this, socket]() { process(socket); }); + if (socket->bytesAvailable() > 0) + process(socket); + } + }); + } + + bool listen() + { + return m_server.listen(QHostAddress::LocalHost); + } + + QString endpoint() const + { + return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()); + } + +private: + void process(QTcpSocket* socket) + { + QByteArray& request = m_requests[socket]; + request.append(socket->readAll()); + const qsizetype headerEnd = request.indexOf("\r\n\r\n"); + if (headerEnd < 0) + return; + + qsizetype contentLength = 0; + for (QByteArray line : request.first(headerEnd).split('\n')) { + line = line.trimmed(); + if (line.toLower().startsWith("content-length:")) { + contentLength = line.mid(sizeof("content-length:") - 1) + .trimmed().toLongLong(); + } + } + if (request.size() - headerEnd - 4 < contentLength) + return; + + m_requests.remove(socket); + const QByteArray payload = QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("result"), m_channelId }, + }).toJson(QJsonDocument::Compact); + QByteArray response = QByteArrayLiteral( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: "); + response += QByteArray::number(payload.size()); + response += QByteArrayLiteral("\r\nConnection: close\r\n\r\n"); + response += payload; + socket->write(response); + socket->disconnectFromHost(); + } + + QTcpServer m_server; + QHash m_requests; + QString m_channelId; +}; + +QByteArray devnetConfig(const QString& channelId, + const QString& ammProgramId, + const QString& definitionId) +{ + return QJsonDocument(QJsonObject { + { QStringLiteral("channelId"), channelId }, + { QStringLiteral("ammProgramId"), ammProgramId }, + { QStringLiteral("tokenDefinitionIds"), QJsonArray { definitionId } }, + }).toJson(QJsonDocument::Compact); +} + +class BackendFixture final { +public: + BackendFixture() + : channelId(64, QLatin1Char('a')), + ammProgramId(64, QLatin1Char('b')), + definitionId(64, QLatin1Char('c')), + tokenProgramId(64, QLatin1Char('d')), + server(channelId) + { + } + + bool initialize(bool deferDefinitionReads = false) + { + if (!server.listen() || !directory.isValid()) + return false; + const QString walletHome = directory.filePath(QStringLiteral("wallet")); + if (!QDir().mkpath(walletHome)) + return false; + const QString configPath = directory.filePath(QStringLiteral("devnet.json")); + QFile config(configPath); + if (!config.open(QIODevice::WriteOnly)) + return false; + const QByteArray configData = devnetConfig(channelId, ammProgramId, definitionId); + if (config.write(configData) != qint64(configData.size())) + return false; + config.close(); + + network = std::make_unique( + QByteArrayLiteral("AMM_UI_NETWORK"), QByteArrayLiteral("devnet")); + devnetFile = std::make_unique( + QByteArrayLiteral("AMM_UI_DEVNET_FILE"), configPath.toLocal8Bit()); + walletHomeEnvironment = std::make_unique( + QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit()); + settingsHome = std::make_unique( + QByteArrayLiteral("XDG_CONFIG_HOME"), + directory.filePath(QStringLiteral("settings")).toLocal8Bit()); + QSettings settings(QStringLiteral("Logos"), QStringLiteral("AmmUI")); + settings.setValue(QStringLiteral("disconnected"), false); + settings.sync(); + + provider.connectResult.adopted = true; + provider.connectResult.snapshot.sequencerAddress = server.endpoint(); + provider.snapshotResult = provider.connectResult.snapshot; + provider.readResult.status = QStringLiteral("ok"); + provider.readResult.programOwner = tokenProgramId; + provider.readResult.dataHex = QStringLiteral( + "0004000000544553540a0000000000000000000000000000000000"); + provider.deferPublicAccountReads = deferDefinitionReads; + backend = std::make_unique(provider); + return true; + } + + QString channelId; + QString ammProgramId; + QString definitionId; + QString tokenProgramId; + LocalRpcServer server; + QTemporaryDir directory; + std::unique_ptr network; + std::unique_ptr devnetFile; + std::unique_ptr walletHomeEnvironment; + std::unique_ptr settingsHome; + FakeWalletProvider provider; + std::unique_ptr backend; +}; +} + +class AmmUiBackendDefinitionCacheTest : public QObject { + Q_OBJECT + +private slots: + void reusesDefinitionsAfterRefreshAndReopen(); + void restartsDefinitionReadAfterRefreshAndReopen(); +}; + +void AmmUiBackendDefinitionCacheTest::reusesDefinitionsAfterRefreshAndReopen() +{ + BackendFixture fixture; + QVERIFY(fixture.initialize()); + + QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready")); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 1); + QCOMPARE(fixture.provider.lastPublicAccountIds, + QStringList { fixture.definitionId }); + + fixture.backend->refreshBalances(); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 1); + + fixture.backend->disconnectWallet(); + QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed")); + QVERIFY(fixture.backend->openExisting()); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 1); +} + +void AmmUiBackendDefinitionCacheTest::restartsDefinitionReadAfterRefreshAndReopen() +{ + BackendFixture fixture; + QVERIFY(fixture.initialize(true)); + + QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready")); + QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 1); + QCOMPARE(fixture.backend->assetStatus(), QStringLiteral("loading")); + + fixture.backend->refreshBalances(); + QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 2); + + fixture.backend->disconnectWallet(); + QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed")); + QVERIFY(fixture.backend->openExisting()); + QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 3); + + fixture.provider.completePendingPublicAccountReads(); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 3); +} + +QTEST_GUILESS_MAIN(AmmUiBackendDefinitionCacheTest) +#include "AmmUiBackendDefinitionCacheTest.moc" diff --git a/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp b/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp new file mode 100644 index 00000000..1eecc303 --- /dev/null +++ b/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp @@ -0,0 +1,185 @@ +#include "FakeWalletProvider.h" +#include "TokenDefinitionCache.h" + +#include + +#include + +namespace { +TokenDefinitionCacheKey cacheKey(const QString& fingerprint = QStringLiteral("channel:one")) +{ + return { + QStringLiteral("devnet"), + fingerprint, + QStringLiteral("http://127.0.0.1:8080"), + { + QString(64, QLatin1Char('a')), + QString(64, QLatin1Char('b')), + }, + }; +} + +void makeReadsReady(FakeWalletProvider& provider) +{ + provider.readResult.status = QStringLiteral("ok"); + provider.readResult.programOwner = QString(64, QLatin1Char('c')); + provider.readResult.dataHex = QStringLiteral("00"); +} +} + +class TokenDefinitionCacheTest : public QObject { + Q_OBJECT + +private slots: + void reusesCompleteReads(); + void retriesIncompleteReads(); + void separatesNetworkKeys(); + void coalescesInFlightReads(); + void restartsCancelledRead(); + void dropsPendingCallbackOnDestruction(); +}; + +void TokenDefinitionCacheTest::reusesCompleteReads() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + + QVector first; + cache.read(key, [&first](QVector reads) { + first = std::move(reads); + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + QCOMPARE(provider.lastPublicAccountIds, key.tokenIds); + QCOMPARE(first.size(), key.tokenIds.size()); + QVERIFY(cache.contains(key)); + + QVector second; + cache.read(key, [&second](QVector reads) { + second = std::move(reads); + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + QCOMPARE(second.size(), first.size()); + for (qsizetype index = 0; index < second.size(); ++index) { + QCOMPARE(second.at(index).accountId, first.at(index).accountId); + QCOMPARE(second.at(index).status, first.at(index).status); + } +} + +void TokenDefinitionCacheTest::retriesIncompleteReads() +{ + FakeWalletProvider provider; + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + + cache.read(key, [](QVector) {}); + cache.read(key, [](QVector) {}); + + QCOMPARE(provider.publicAccountReadCalls, 2); + QVERIFY(!cache.contains(key)); +} + +void TokenDefinitionCacheTest::separatesNetworkKeys() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey baseKey = cacheKey(); + TokenDefinitionCacheKey fingerprintKey = baseKey; + fingerprintKey.networkFingerprint = QStringLiteral("channel:two"); + TokenDefinitionCacheKey endpointKey = baseKey; + endpointKey.sequencerAddress = QStringLiteral("http://127.0.0.1:8081"); + TokenDefinitionCacheKey definitionsKey = baseKey; + definitionsKey.tokenIds = { + baseKey.tokenIds.at(1), + baseKey.tokenIds.at(0), + }; + TokenDefinitionCacheKey networkKey = baseKey; + networkKey.networkId = QStringLiteral("testnet"); + + cache.read(baseKey, [](QVector) {}); + cache.read(fingerprintKey, [](QVector) {}); + cache.read(endpointKey, [](QVector) {}); + cache.read(definitionsKey, [](QVector) {}); + cache.read(networkKey, [](QVector) {}); + + QCOMPARE(provider.publicAccountReadCalls, 5); + QVERIFY(!cache.contains(baseKey)); + QVERIFY(cache.contains(networkKey)); +} + +void TokenDefinitionCacheTest::coalescesInFlightReads() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + provider.deferPublicAccountReads = true; + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + bool firstCalled = false; + bool secondCalled = false; + + cache.read(key, [&firstCalled](QVector) { + firstCalled = true; + }); + cache.read(key, [&secondCalled](QVector) { + secondCalled = true; + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + provider.completePendingPublicAccountReads(); + + QVERIFY(firstCalled); + QVERIFY(secondCalled); + QVERIFY(cache.contains(key)); +} + +void TokenDefinitionCacheTest::restartsCancelledRead() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + provider.deferPublicAccountReads = true; + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + bool cancelledCallback = false; + bool retryCallback = false; + + cache.read(key, [&cancelledCallback](QVector) { + cancelledCallback = true; + }); + cache.cancelPending(); + cache.read(key, [&retryCallback](QVector) { + retryCallback = true; + }); + + QCOMPARE(provider.publicAccountReadCalls, 2); + provider.completePendingPublicAccountReads(); + + QVERIFY(!cancelledCallback); + QVERIFY(retryCallback); + QVERIFY(cache.contains(key)); +} + +void TokenDefinitionCacheTest::dropsPendingCallbackOnDestruction() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + provider.deferPublicAccountReads = true; + const TokenDefinitionCacheKey key = cacheKey(); + bool callbackCalled = false; + + { + TokenDefinitionCache cache(provider); + cache.read(key, [&callbackCalled](QVector) { + callbackCalled = true; + }); + } + provider.completePendingPublicAccountReads(); + + QVERIFY(!callbackCalled); +} + +QTEST_GUILESS_MAIN(TokenDefinitionCacheTest) +#include "TokenDefinitionCacheTest.moc" diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h index 477b7251..1beb6716 100644 --- a/apps/shared/wallet/tests/support/FakeWalletProvider.h +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -20,12 +20,21 @@ class FakeWalletProvider final : public WalletProvider { int clearCalls = 0; int createAccountCalls = 0; mutable int readCalls = 0; + int publicAccountReadCalls = 0; int submitCalls = 0; int disconnectCalls = 0; bool lastForceRefresh = false; bool lastAccountWasPublic = false; WalletPaths lastPaths; WalletTransaction lastTransaction; + QStringList lastPublicAccountIds; + bool deferPublicAccountReads = false; + + struct PendingPublicAccountRead { + QStringList accountIds; + AccountReadsCallback callback; + }; + QVector pendingPublicAccountReads; WalletSession connect(const WalletPaths& paths) override { @@ -83,16 +92,21 @@ class FakeWalletProvider final : public WalletProvider { void readPublicAccountsAsync(const QStringList& accountIds, AccountReadsCallback callback) override { - QVector results = readResults; - if (results.isEmpty()) { - results.reserve(accountIds.size()); - for (const QString& accountId : accountIds) { - WalletAccountRead result = readResult; - result.accountId = accountId; - results.append(std::move(result)); - } + ++publicAccountReadCalls; + lastPublicAccountIds = accountIds; + if (deferPublicAccountReads) { + pendingPublicAccountReads.append({ accountIds, std::move(callback) }); + return; } - callback(std::move(results)); + callback(accountReads(accountIds)); + } + + void completePendingPublicAccountReads() + { + QVector pending; + pending.swap(pendingPublicAccountReads); + for (PendingPublicAccountRead& read : pending) + read.callback(accountReads(read.accountIds)); } WalletSubmission submitPublicTransaction( @@ -104,4 +118,19 @@ class FakeWalletProvider final : public WalletProvider { } void disconnect() override { ++disconnectCalls; } + +private: + QVector accountReads(const QStringList& accountIds) const + { + QVector results = readResults; + if (results.isEmpty()) { + results.reserve(accountIds.size()); + for (const QString& accountId : accountIds) { + WalletAccountRead result = readResult; + result.accountId = accountId; + results.append(std::move(result)); + } + } + return results; + } }; From 9049d31e70633d593b5d2589c39b76c75f1f1c46 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Sat, 18 Jul 2026 19:38:14 -0300 Subject: [PATCH 12/14] chore(wallet): synchronize shared wallet module --- apps/shared/wallet/CMakeLists.txt | 12 +- .../qml/TransactionConfirmationDialog.qml | 116 +++- apps/shared/wallet/qml/WalletControl.qml | 59 +- .../wallet/qml/internal/AccountDelegate.qml | 2 + .../shared/wallet/qml/internal/CopyButton.qml | 21 +- .../qml/internal/CreateAccountDialog.qml | 6 +- .../qml/internal/CreateWalletDialog.qml | 1 + .../qml/internal/WalletMessageDialog.qml | 5 + .../shared/wallet/src/LogosWalletProvider.cpp | 355 ++++++++++-- apps/shared/wallet/src/LogosWalletProvider.h | 8 +- apps/shared/wallet/src/WalletAccountId.cpp | 58 +- apps/shared/wallet/src/WalletAccountId.h | 1 + apps/shared/wallet/src/WalletAccountModel.cpp | 32 +- apps/shared/wallet/src/WalletAccountModel.h | 2 +- apps/shared/wallet/src/WalletController.cpp | 199 +++++-- apps/shared/wallet/src/WalletController.h | 8 + apps/shared/wallet/src/WalletProvider.h | 5 + .../tests/cpp/LogosWalletProviderTest.cpp | 511 ++++++++++++++++-- .../wallet/tests/cpp/fixtures/logos_sdk.h | 48 ++ .../wallet/tests/qml/tst_CopyButton.qml | 56 ++ .../qml/tst_TransactionConfirmationDialog.qml | 32 ++ .../wallet/tests/qml/tst_WalletControl.qml | 207 ++++--- .../wallet/tests/support/FakeWalletProvider.h | 38 +- 23 files changed, 1530 insertions(+), 252 deletions(-) create mode 100644 apps/shared/wallet/tests/qml/tst_CopyButton.qml diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 32adf6a4..8c32c116 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -61,13 +61,13 @@ if(LOGOS_WALLET_BUILD_QML) set(wallet_qml_output_dir "${CMAKE_CURRENT_BINARY_DIR}/qml/Logos/Wallet") set(wallet_internal_qml qml/internal/WalletIconButton.qml - qml/internal/CopyButton.qml qml/internal/AccountDelegate.qml qml/internal/CreateAccountDialog.qml qml/internal/CreateWalletDialog.qml qml/internal/WalletMessageDialog.qml ) set(wallet_public_qml + qml/internal/CopyButton.qml qml/WalletControl.qml qml/TransactionConfirmationDialog.qml qml/SubmittedTransaction.qml @@ -172,8 +172,16 @@ if(BUILD_TESTING) target_link_libraries(logos_wallet_qml_test PRIVATE Qt6::QuickTest) add_dependencies(logos_wallet_qml_test logos_wallet_qmlplugin) add_test(NAME logos_wallet_qml COMMAND logos_wallet_qml_test) + get_target_property(wallet_qml_library Qt6::Qml IMPORTED_LOCATION) + get_filename_component(wallet_qml_library_dir "${wallet_qml_library}" DIRECTORY) + get_filename_component(wallet_qml_prefix "${wallet_qml_library_dir}" DIRECTORY) + set(wallet_qml_test_import_paths + "${CMAKE_CURRENT_BINARY_DIR}/qml" + "${wallet_qml_prefix}/${QT6_INSTALL_QML}" + ) + string(JOIN ":" wallet_qml_test_import_path ${wallet_qml_test_import_paths}) set_tests_properties(logos_wallet_qml PROPERTIES ENVIRONMENT - "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml;QML_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml" + "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${wallet_qml_test_import_path};QML_IMPORT_PATH=${wallet_qml_test_import_path}" ) endif() endif() diff --git a/apps/shared/wallet/qml/TransactionConfirmationDialog.qml b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml index 141a8db8..76cc91a0 100644 --- a/apps/shared/wallet/qml/TransactionConfirmationDialog.qml +++ b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml @@ -9,12 +9,21 @@ Popup { property string cancelText: qsTr("Cancel") property string confirmText: qsTr("Confirm") property bool busy: false + property string busyText: qsTr("Submitting…") + property bool activityBusy: false + property string activityText: qsTr("Updating…") + property bool showInlineBusyIndicator: true property var snapshot: ({}) property Component summary: null property bool confirmationPending: false + property bool confirmEnabled: true + property bool roundedCancelButton: false + property bool closeWhenSettled: true + readonly property bool actionPending: root.busy || root.activityBusy signal canceled signal confirmed(var snapshot) + signal summaryEdited(var snapshot) modal: true dim: true @@ -40,7 +49,14 @@ Popup { root.snapshot = root.cloneSnapshot(nextSnapshot) root.confirmationPending = false root.open() - cancelButton.forceActiveFocus() + Qt.callLater(function() { + if (cancelButtonLoader.item) + cancelButtonLoader.item.forceActiveFocus() + }) + } + + function updateSnapshot(nextSnapshot) { + root.snapshot = root.cloneSnapshot(nextSnapshot) } function cancel() { @@ -52,7 +68,7 @@ Popup { } function confirm() { - if (root.busy) + if (root.actionPending || !root.confirmEnabled) return root.confirmationPending = true root.confirmed(root.snapshot) @@ -62,10 +78,21 @@ Popup { } } + Connections { + target: summaryLoader.item + ignoreUnknownSignals: true + + function onSnapshotEdited(snapshot) { + root.updateSnapshot(snapshot) + root.summaryEdited(root.snapshot) + } + } + onBusyChanged: { if (!root.busy && root.confirmationPending) { root.confirmationPending = false - root.close() + if (root.closeWhenSettled) + root.close() } } @@ -117,26 +144,37 @@ Popup { } } - BusyIndicator { + Item { + id: inlineBusyIndicator + + property bool active: root.showInlineBusyIndicator && root.actionPending Layout.alignment: Qt.AlignHCenter - visible: root.busy - running: root.busy - Accessible.name: qsTr("Submitting transaction") + Layout.preferredWidth: active ? busySpinner.implicitWidth : 0 + Layout.preferredHeight: active ? busySpinner.implicitHeight : 0 + implicitWidth: busySpinner.implicitWidth + implicitHeight: busySpinner.implicitHeight + visible: active + + BusyIndicator { + id: busySpinner + + anchors.centerIn: parent + running: inlineBusyIndicator.active + Accessible.name: root.busy ? root.busyText : root.activityText + } } RowLayout { Layout.fillWidth: true spacing: 10 - Button { - id: cancelButton - objectName: "transactionCancelButton" + Loader { + id: cancelButtonLoader + objectName: "transactionCancelButtonLoader" Layout.fillWidth: true - implicitHeight: 44 - text: root.cancelText - enabled: !root.busy - Accessible.name: text - onClicked: root.cancel() + Layout.preferredHeight: 44 + sourceComponent: root.roundedCancelButton + ? roundedCancelButtonComponent : defaultCancelButtonComponent } Button { @@ -144,8 +182,8 @@ Popup { objectName: "transactionConfirmButton" Layout.fillWidth: true implicitHeight: 44 - text: root.busy ? qsTr("Submitting...") : root.confirmText - enabled: !root.busy + text: root.busy ? root.busyText : root.confirmText + enabled: !root.actionPending && root.confirmEnabled Accessible.name: text onClicked: root.confirm() @@ -166,4 +204,48 @@ Popup { } } } + + Component { + id: defaultCancelButtonComponent + + Button { + objectName: "transactionCancelButton" + anchors.fill: parent + text: root.cancelText + enabled: !root.busy + Accessible.name: text + onClicked: root.cancel() + } + } + + Component { + id: roundedCancelButtonComponent + + Button { + id: cancelButton + + objectName: "transactionCancelButton" + anchors.fill: parent + text: root.cancelText + enabled: !root.busy + Accessible.name: text + onClicked: root.cancel() + + background: Rectangle { + color: cancelButton.pressed ? "#3f3f46" + : cancelButton.hovered ? "#27272a" : "#18181b" + border.color: "#52525b" + border.width: 1 + radius: 6 + } + + contentItem: Label { + text: cancelButton.text + color: "#f4f4f5" + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } } diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml index 0136de7c..1536781d 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -17,11 +17,15 @@ Item { property bool openPending: false property bool advancedExpanded: false property bool availableExpanded: false + property bool primarySelectionQueued: false property string postCreationWarning: "" property bool createdWalletAwaitingAcknowledgement: false property string reportedOpenErrorKey: "" readonly property bool connected: root.wallet !== null && root.wallet.isWalletOpen + readonly property string syncStatus: root.wallet + ? String(root.wallet.walletSyncStatus || "closed") + : "closed" readonly property bool compactLayout: root.compact || root.viewportWidth < 680 readonly property bool walletOpening: root.wallet !== null && (root.wallet.walletSyncStatus === "opening" @@ -52,7 +56,11 @@ Item { required property bool canBePrimary required property string kind } - onCountChanged: root.syncPrimarySelection() + onCountChanged: { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + onObjectAdded: root.schedulePrimarySelection() } function accountAt(index, field) { @@ -78,6 +86,8 @@ Item { ? root.wallet.primaryAccountAddress : "" for (let index = 0; index < accounts.count; ++index) { const account = accounts.objectAt(index) + if (!account) + continue if ((requested.length > 0 && account.address === requested) || account.isPrimary) { root.selectedIndex = index return @@ -85,6 +95,8 @@ Item { } for (let index = 0; index < accounts.count; ++index) { const account = accounts.objectAt(index) + if (!account) + continue if (account.kind === "user" && account.canBePrimary) { root.selectedIndex = index return @@ -93,6 +105,19 @@ Item { root.selectedIndex = -1 } + function schedulePrimarySelection() { + if (root.primarySelectionQueued) + return + root.primarySelectionQueued = true + Qt.callLater(function() { + root.primarySelectionQueued = false + if (root.connected) + root.syncPrimarySelection() + else + root.selectedIndex = -1 + }) + } + function shortAddress(address) { return address && address.length > 13 ? address.substring(0, 6) + "…" + address.substring(address.length - 4) @@ -140,6 +165,7 @@ Item { if (key === root.reportedOpenErrorKey) return root.reportedOpenErrorKey = key + root.busy = false root.showError(root.openFailureMessage()) } @@ -234,16 +260,32 @@ Item { Connections { target: root.accountModel ignoreUnknownSignals: true - function onModelReset() { root.syncPrimarySelection() } - function onRowsInserted() { root.syncPrimarySelection() } - function onRowsRemoved() { root.syncPrimarySelection() } - function onDataChanged() { root.syncPrimarySelection() } + function onModelReset() { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + function onRowsInserted() { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + function onRowsRemoved() { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } + function onDataChanged() { + root.syncPrimarySelection() + if (root.selectedIndex < 0) + root.schedulePrimarySelection() + } } Connections { target: root.wallet ignoreUnknownSignals: true - function onPrimaryAccountAddressChanged() { root.syncPrimarySelection() } + function onPrimaryAccountAddressChanged() { + root.syncPrimarySelection() + root.schedulePrimarySelection() + } function onWalletSyncStatusChanged() { if (!root.wallet || root.wallet.walletSyncStatus !== "error") root.reportedOpenErrorKey = "" @@ -266,6 +308,7 @@ Item { walletMenu.close() } else { root.syncPrimarySelection() + root.schedulePrimarySelection() } } onViewportWidthChanged: { @@ -374,6 +417,7 @@ Item { Popup { id: walletMenu objectName: "walletMenu" + palette.windowText: "#d4d4d8" property real lastClosedMs: 0 property point anchorPosition: Qt.point(0, 0) readonly property var viewport: Overlay.overlay @@ -395,6 +439,7 @@ Item { height: Math.min(implicitHeight, availableMenuHeight) margins: 12 padding: 12 + focus: true closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside function updateAnchor() { @@ -506,6 +551,8 @@ Item { } } Label { + objectName: "walletPrimaryAccountType" + visible: root.selectedIndex >= 0 text: root.selectedIsPublic ? qsTr("Public user account") : qsTr("Private account") color: "#a1a1aa" diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml index b0918fc1..ea5da2ba 100644 --- a/apps/shared/wallet/qml/internal/AccountDelegate.qml +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -119,6 +119,7 @@ ItemDelegate { spacing: 6 Button { + objectName: "walletRenameButton" text: qsTr("Rename") flat: true onClicked: root.renameRequested(root.address, root.alias) @@ -127,6 +128,7 @@ ItemDelegate { Item { Layout.fillWidth: true } Button { + objectName: "walletMakePrimaryButton" visible: root.canBePrimary && !root.isPrimary text: qsTr("Make primary") flat: true diff --git a/apps/shared/wallet/qml/internal/CopyButton.qml b/apps/shared/wallet/qml/internal/CopyButton.qml index 1964da3c..b2f664fb 100644 --- a/apps/shared/wallet/qml/internal/CopyButton.qml +++ b/apps/shared/wallet/qml/internal/CopyButton.qml @@ -5,9 +5,11 @@ WalletIconButton { signal copyRequested + property string copyText: "" + property string copyLabel: qsTr("Copy") property bool copied: false - accessibleName: root.copied ? qsTr("Copied") : qsTr("Copy") + accessibleName: root.copied ? qsTr("Copied") : root.copyLabel iconSource: root.copied ? Qt.resolvedUrl("icons/checkmark.svg") : Qt.resolvedUrl("icons/copy.svg") @@ -18,7 +20,24 @@ WalletIconButton { onTriggered: root.copied = false } + TextEdit { + id: clipboardProxy + + visible: false + } + + function copyToClipboard() { + if (root.copyText.length === 0) + return + clipboardProxy.text = root.copyText + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + onClicked: { + root.copyToClipboard() root.copyRequested() root.copied = true resetTimer.restart() diff --git a/apps/shared/wallet/qml/internal/CreateAccountDialog.qml b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml index 51d5f31f..b4998da1 100644 --- a/apps/shared/wallet/qml/internal/CreateAccountDialog.qml +++ b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml @@ -16,9 +16,13 @@ Popup { x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 padding: 20 + focus: true closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside - onOpened: privateSwitch.checked = false + onOpened: { + privateSwitch.checked = false + Qt.callLater(function() { privateSwitch.forceActiveFocus() }) + } background: Rectangle { color: "#18181b" diff --git a/apps/shared/wallet/qml/internal/CreateWalletDialog.qml b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml index ac422c1f..e1ca2dee 100644 --- a/apps/shared/wallet/qml/internal/CreateWalletDialog.qml +++ b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml @@ -20,6 +20,7 @@ Popup { x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 padding: 20 + focus: true closePolicy: root.busy || root.mnemonic.length > 0 ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside diff --git a/apps/shared/wallet/qml/internal/WalletMessageDialog.qml b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml index 7f7a3467..47c39633 100644 --- a/apps/shared/wallet/qml/internal/WalletMessageDialog.qml +++ b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml @@ -15,8 +15,11 @@ Popup { x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 padding: 20 + focus: true closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + onOpened: Qt.callLater(function() { closeButton.forceActiveFocus() }) + background: Rectangle { color: "#18181b" border.color: "#3f3f46" @@ -44,6 +47,8 @@ Popup { } Button { + id: closeButton + Layout.alignment: Qt.AlignRight text: qsTr("Close") onClicked: root.close() diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp index 4a001894..394c01c9 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.cpp +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -1,10 +1,13 @@ #include "LogosWalletProvider.h" +#include + #include #include #include #include #include +#include #include #include #include @@ -114,6 +117,58 @@ void applyPublicRead(WalletAccount& account, const WalletAccountRead& read) account.programOwner = read.programOwner; account.dataHex = read.dataHex; } + +bool encodeTransaction(const WalletTransaction& transaction, + QVariantList* signingRequirements, + QVariantList* instruction) +{ + if (!isHex(transaction.programId, 64) + || transaction.accountIds.size() != transaction.signingRequirements.size()) { + return false; + } + for (const QString& accountId : transaction.accountIds) { + if (!isHex(accountId, 64)) + return false; + } + + signingRequirements->reserve(transaction.signingRequirements.size()); + for (bool required : transaction.signingRequirements) + signingRequirements->append(required); + + instruction->reserve(transaction.instruction.size()); + for (quint32 word : transaction.instruction) + instruction->append(word); + return true; +} + +WalletSubmission parseSubmission(const QString& response) +{ + WalletSubmission submission; + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + const QJsonObject result = document.object(); + const QJsonValue success = result.value(QStringLiteral("success")); + const QJsonValue error = result.value(QStringLiteral("error")); + const QString hash = result.value(QStringLiteral("tx_hash")).toString(); + const bool emptyError = error.isUndefined() + || error.isNull() + || (error.isString() && error.toString().isEmpty()); + if (!success.isBool() + || !success.toBool() + || !emptyError + || !isHex(hash, 64, false)) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + submission.nativeHash = hash.toLower(); + return submission; +} } struct LogosWalletProvider::Impl { @@ -143,12 +198,16 @@ LogosWalletProvider::LogosWalletProvider(LogosModules* logos) LogosWalletProvider::~LogosWalletProvider() { + ++m_generation; + ++m_sessionGeneration; if (m_connected) save(); } WalletSession LogosWalletProvider::connect(const WalletPaths& paths) { + ++m_generation; + ++m_sessionGeneration; clearSnapshot(); if (!m_impl->logos) return failedSession(WalletFailure::WalletUnavailable); @@ -173,6 +232,7 @@ WalletSession LogosWalletProvider::connect(const WalletPaths& paths) void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback callback) { + ++m_sessionGeneration; clearSnapshot(); const quint64 generation = ++m_generation; if (!m_impl->logos) { @@ -236,6 +296,8 @@ void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, const QString& password) { + ++m_generation; + ++m_sessionGeneration; clearSnapshot(); if (!m_impl->logos) return failedCreation(WalletFailure::WalletUnavailable); @@ -260,8 +322,6 @@ WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, return creation; } - creation.snapshot = snapshot(true); - creation.failure = creation.snapshot.failure; return creation; } @@ -329,19 +389,203 @@ WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic) return creation; } - creation.snapshot = snapshot(true); - if (isPublic) { - for (const WalletAccountRead& read : creation.snapshot.publicAccountReads) { - if (read.accountId == creation.accountId) { - creation.publicAccount = read; - return creation; - } - } + if (isPublic) creation.publicAccount = readPublicAccount(creation.accountId); + if (m_snapshotReady) { + WalletAccount account; + account.address = creation.accountId; + account.isPublic = isPublic; + if (isPublic && creation.publicAccount.ok()) { + account.balance = littleEndianU128ToDecimal(creation.publicAccount.balanceHex); + auto read = std::find_if( + m_snapshot.publicAccountReads.begin(), + m_snapshot.publicAccountReads.end(), + [&creation](const WalletAccountRead& existing) { + return existing.accountId == creation.accountId; + }); + if (read == m_snapshot.publicAccountReads.end()) + m_snapshot.publicAccountReads.append(creation.publicAccount); + else + *read = creation.publicAccount; + } else { + account.balance = m_impl->logos->logos_execution_zone.get_balance( + creation.accountId, isPublic); + } + auto existing = std::find_if( + m_snapshot.accounts.begin(), m_snapshot.accounts.end(), + [&creation](const WalletAccount& candidate) { + return candidate.address == creation.accountId; + }); + if (existing == m_snapshot.accounts.end()) + m_snapshot.accounts.append(account); + else + *existing = account; + creation.snapshot = m_snapshot; } return creation; } +void LogosWalletProvider::createAccountAsync(bool isPublic, + AccountCreationCallback callback) +{ + QPointer guard(this); + if (!m_connected || !m_impl->logos) { + QTimer::singleShot(0, [guard, callback = std::move(callback)]() mutable { + if (!guard) + return; + WalletAccountCreation creation; + creation.failure = WalletFailure::WalletUnavailable; + callback(std::move(creation)); + }); + return; + } + + const quint64 sessionGeneration = m_sessionGeneration; + auto finish = [guard, sessionGeneration, isPublic, + callback = std::move(callback)]( + WalletAccountCreation creation, QString fallbackBalance) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + callback(std::move(failed)); + return; + } + + if (guard->m_snapshotReady) { + WalletAccount account; + account.address = creation.accountId; + account.isPublic = isPublic; + if (isPublic && creation.publicAccount.ok()) { + account.balance = littleEndianU128ToDecimal( + creation.publicAccount.balanceHex); + auto read = std::find_if( + guard->m_snapshot.publicAccountReads.begin(), + guard->m_snapshot.publicAccountReads.end(), + [&creation](const WalletAccountRead& existing) { + return existing.accountId == creation.accountId; + }); + if (read == guard->m_snapshot.publicAccountReads.end()) + guard->m_snapshot.publicAccountReads.append(creation.publicAccount); + else + *read = creation.publicAccount; + } else { + account.balance = std::move(fallbackBalance); + } + auto existing = std::find_if( + guard->m_snapshot.accounts.begin(), + guard->m_snapshot.accounts.end(), + [&creation](const WalletAccount& candidate) { + return candidate.address == creation.accountId; + }); + if (existing == guard->m_snapshot.accounts.end()) + guard->m_snapshot.accounts.append(account); + else + *existing = account; + creation.snapshot = guard->m_snapshot; + } + callback(std::move(creation)); + }; + + auto created = [guard, sessionGeneration, isPublic, + finish = std::move(finish)](QString accountId) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + + WalletAccountCreation creation; + creation.accountId = std::move(accountId); + if (!isHex(creation.accountId, 64)) { + creation.failure = WalletFailure::CreateFailed; + finish(std::move(creation), {}); + return; + } + + guard->m_impl->logos->logos_execution_zone.saveAsync( + [guard, sessionGeneration, isPublic, creation = std::move(creation), + finish = std::move(finish)](int result) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + if (result != WALLET_FFI_SUCCESS) { + creation.failure = WalletFailure::SaveFailed; + finish(std::move(creation), {}); + return; + } + + if (!isPublic) { + guard->m_impl->logos->logos_execution_zone.get_balanceAsync( + creation.accountId, false, + [guard, sessionGeneration, creation = std::move(creation), + finish = std::move(finish)](QString balance) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + finish(std::move(creation), std::move(balance)); + }); + return; + } + + const QString accountId = creation.accountId; + guard->m_impl->logos->logos_execution_zone.get_account_publicAsync( + accountId, + [guard, sessionGeneration, creation = std::move(creation), + finish = std::move(finish)](QString payload) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + creation.publicAccount = parsePublicAccount( + creation.accountId, payload); + if (creation.publicAccount.ok()) { + finish(std::move(creation), {}); + return; + } + guard->m_impl->logos->logos_execution_zone.get_balanceAsync( + creation.accountId, true, + [guard, sessionGeneration, + creation = std::move(creation), + finish = std::move(finish)](QString balance) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + finish(std::move(creation), std::move(balance)); + }); + }); + }); + }; + + if (isPublic) + m_impl->logos->logos_execution_zone.create_account_publicAsync(std::move(created)); + else + m_impl->logos->logos_execution_zone.create_account_privateAsync(std::move(created)); +} + WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const { if (!m_impl->logos || !isHex(accountId, 64)) @@ -399,27 +643,12 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction( submission.failure = WalletFailure::WalletUnavailable; return submission; } - if (!isHex(transaction.programId, 64) - || transaction.accountIds.size() != transaction.signingRequirements.size()) { + QVariantList signingRequirements; + QVariantList instruction; + if (!encodeTransaction(transaction, &signingRequirements, &instruction)) { submission.failure = WalletFailure::InvalidRequest; return submission; } - for (const QString& accountId : transaction.accountIds) { - if (!isHex(accountId, 64)) { - submission.failure = WalletFailure::InvalidRequest; - return submission; - } - } - - QVariantList signingRequirements; - signingRequirements.reserve(transaction.signingRequirements.size()); - for (bool required : transaction.signingRequirements) - signingRequirements.append(required); - - QVariantList instruction; - instruction.reserve(transaction.instruction.size()); - for (quint32 word : transaction.instruction) - instruction.append(word); const QString response = m_impl->logos->logos_execution_zone.send_generic_public_transaction( @@ -427,36 +656,60 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction( signingRequirements, QVariant::fromValue(instruction), transaction.programId); + return parseSubmission(response); +} - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) { - submission.failure = WalletFailure::SubmissionFailed; - return submission; +void LogosWalletProvider::submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) +{ + QPointer guard(this); + WalletSubmission submission; + if (!m_connected || !m_impl->logos) { + submission.failure = WalletFailure::WalletUnavailable; + QTimer::singleShot(0, [guard, callback = std::move(callback), + submission = std::move(submission)]() mutable { + if (guard) + callback(std::move(submission)); + }); + return; } - const QJsonObject result = document.object(); - const QJsonValue success = result.value(QStringLiteral("success")); - const QJsonValue error = result.value(QStringLiteral("error")); - const QString hash = result.value(QStringLiteral("tx_hash")).toString(); - const bool emptyError = error.isUndefined() - || error.isNull() - || (error.isString() && error.toString().isEmpty()); - if (!success.isBool() - || !success.toBool() - || !emptyError - || !isHex(hash, 64, false)) { - submission.failure = WalletFailure::SubmissionFailed; - return submission; + QVariantList signingRequirements; + QVariantList instruction; + if (!encodeTransaction(transaction, &signingRequirements, &instruction)) { + submission.failure = WalletFailure::InvalidRequest; + QTimer::singleShot(0, [guard, callback = std::move(callback), + submission = std::move(submission)]() mutable { + if (guard) + callback(std::move(submission)); + }); + return; } - submission.nativeHash = hash.toLower(); - return submission; + const quint64 sessionGeneration = m_sessionGeneration; + m_impl->logos->logos_execution_zone.send_generic_public_transactionAsync( + transaction.accountIds, + signingRequirements, + QVariant::fromValue(instruction), + transaction.programId, + [guard, sessionGeneration, callback = std::move(callback)]( + QString response) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletSubmission failed; + failed.failure = WalletFailure::WalletUnavailable; + callback(std::move(failed)); + return; + } + callback(parseSubmission(response)); + }); } void LogosWalletProvider::disconnect() { ++m_generation; + ++m_sessionGeneration; if (m_connected) save(); clearSnapshot(); @@ -589,16 +842,12 @@ void LogosWalletProvider::loadSnapshotAsync(quint64 generation, SnapshotCallback {}, entry.value(QStringLiteral("is_public"), true).toBool(), }; - if (!state->snapshot.accounts.at(index).isPublic) { - state->snapshot.accounts[index].readStatus = - QStringLiteral("private"); - } state->publicFlags[index] = state->snapshot.accounts.at(index).isPublic; } auto finishOne = std::make_shared>(); - *finishOne = [this, generation, state, finishOne]() mutable { + *finishOne = [this, generation, state]() mutable { if (generation != m_generation || --state->remaining > 0) return; for (qsizetype index = 0; diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h index a22aac8e..32b0137e 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.h +++ b/apps/shared/wallet/src/LogosWalletProvider.h @@ -2,12 +2,14 @@ #include +#include + #include "WalletProvider.h" class LogosAPI; struct LogosModules; -class LogosWalletProvider final : public WalletProvider { +class LogosWalletProvider final : public QObject, public WalletProvider { public: explicit LogosWalletProvider(LogosAPI* api); explicit LogosWalletProvider(LogosModules* logos); @@ -21,11 +23,14 @@ class LogosWalletProvider final : public WalletProvider { void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override; void clearSnapshot() override; WalletAccountCreation createAccount(bool isPublic) override; + void createAccountAsync(bool isPublic, AccountCreationCallback callback) override; WalletAccountRead readPublicAccount(const QString& accountId) const override; void readPublicAccountsAsync(const QStringList& accountIds, AccountReadsCallback callback) override; WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) override; + void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) override; void disconnect() override; private: @@ -40,4 +45,5 @@ class LogosWalletProvider final : public WalletProvider { bool m_snapshotReady = false; bool m_connected = false; quint64 m_generation = 0; + quint64 m_sessionGeneration = 0; }; diff --git a/apps/shared/wallet/src/WalletAccountId.cpp b/apps/shared/wallet/src/WalletAccountId.cpp index 76903c70..4d3d0639 100644 --- a/apps/shared/wallet/src/WalletAccountId.cpp +++ b/apps/shared/wallet/src/WalletAccountId.cpp @@ -6,6 +6,9 @@ namespace { constexpr char BASE58_ALPHABET[] = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +constexpr qsizetype ACCOUNT_ID_BYTES = 32; +constexpr qsizetype MIN_BASE58_ACCOUNT_ID_SIZE = 32; +constexpr qsizetype MAX_BASE58_ACCOUNT_ID_SIZE = 44; bool isHexCharacter(QChar character) { @@ -14,6 +17,18 @@ bool isHexCharacter(QChar character) || (value >= 'a' && value <= 'f') || (value >= 'A' && value <= 'F'); } + +int base58Digit(QChar character) +{ + const ushort value = character.unicode(); + if (value > 0x7f) + return -1; + for (int digit = 0; BASE58_ALPHABET[digit] != '\0'; ++digit) { + if (BASE58_ALPHABET[digit] == static_cast(value)) + return digit; + } + return -1; +} } QString walletAccountIdToBase58(const QString& accountId) @@ -26,7 +41,7 @@ QString walletAccountIdToBase58(const QString& accountId) } const QByteArray bytes = QByteArray::fromHex(accountId.toLatin1()); - if (bytes.size() != 32) + if (bytes.size() != ACCOUNT_ID_BYTES) return {}; qsizetype leadingZeroes = 0; @@ -54,3 +69,44 @@ QString walletAccountIdToBase58(const QString& accountId) encoded.append(QLatin1Char(BASE58_ALPHABET[*digit])); return encoded; } + +QString walletAccountIdFromBase58(const QString& accountId) +{ + if (accountId.size() < MIN_BASE58_ACCOUNT_ID_SIZE + || accountId.size() > MAX_BASE58_ACCOUNT_ID_SIZE) { + return {}; + } + + qsizetype leadingZeroes = 0; + while (leadingZeroes < accountId.size() + && accountId.at(leadingZeroes) == QLatin1Char('1')) { + ++leadingZeroes; + } + + QVector bytes; + bytes.reserve(ACCOUNT_ID_BYTES); + for (const QChar character : accountId) { + int carry = base58Digit(character); + if (carry < 0) + return {}; + for (unsigned char& byte : bytes) { + carry += static_cast(byte) * 58; + byte = static_cast(carry % 256); + carry /= 256; + } + while (carry > 0) { + bytes.append(static_cast(carry % 256)); + carry /= 256; + } + } + + if (leadingZeroes > ACCOUNT_ID_BYTES + || bytes.size() != ACCOUNT_ID_BYTES - leadingZeroes) { + return {}; + } + + QByteArray decoded(ACCOUNT_ID_BYTES, '\0'); + for (qsizetype index = 0; index < bytes.size(); ++index) + decoded[ACCOUNT_ID_BYTES - index - 1] = static_cast(bytes.at(index)); + return QString::fromLatin1(decoded.toHex()); +} diff --git a/apps/shared/wallet/src/WalletAccountId.h b/apps/shared/wallet/src/WalletAccountId.h index ad7cf3e2..57fd8a76 100644 --- a/apps/shared/wallet/src/WalletAccountId.h +++ b/apps/shared/wallet/src/WalletAccountId.h @@ -3,3 +3,4 @@ #include QString walletAccountIdToBase58(const QString& accountId); +QString walletAccountIdFromBase58(const QString& accountId); diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp index f9b52626..8b93327a 100644 --- a/apps/shared/wallet/src/WalletAccountModel.cpp +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -127,7 +127,7 @@ void WalletAccountModel::replaceAccounts(const QVector& accounts, emit countChanged(); } -void WalletAccountModel::applyPresentations( +bool WalletAccountModel::applyPresentations( const QVector& presentations) { QHash rowsByAddress; @@ -141,10 +141,14 @@ void WalletAccountModel::applyPresentations( int firstChanged = m_accounts.size(); int lastChanged = -1; for (const WalletAccountPresentation& presentation : presentations) { - const auto row = rowsByAddress.constFind(presentation.address); + const QString decodedAddress = walletAccountIdFromBase58(presentation.address); + const QString& address = decodedAddress.isEmpty() + ? presentation.address : decodedAddress; + const auto row = rowsByAddress.constFind(address); if (row == rowsByAddress.cend()) continue; - Entry& entry = m_accounts[row.value()]; + const Entry current = m_accounts.at(row.value()); + Entry entry = current; if (!presentation.kind.isEmpty()) entry.kind = presentation.kind; entry.programName = presentation.programName; @@ -157,13 +161,32 @@ void WalletAccountModel::applyPresentations( if (!entry.canBePrimary) entry.isPrimary = false; updateEntryName(entry); + if (entry.alias == current.alias + && entry.semanticName == current.semanticName + && entry.name == current.name + && entry.address == current.address + && entry.displayAddress == current.displayAddress + && entry.balance == current.balance + && entry.isPublic == current.isPublic + && entry.kind == current.kind + && entry.section == current.section + && entry.programOwner == current.programOwner + && entry.readStatus == current.readStatus + && entry.programName == current.programName + && entry.accountType == current.accountType + && entry.definitionId == current.definitionId + && entry.canBePrimary == current.canBePrimary + && entry.isPrimary == current.isPrimary) { + continue; + } + m_accounts[row.value()] = std::move(entry); if (row.value() < firstChanged) firstChanged = row.value(); if (row.value() > lastChanged) lastChanged = row.value(); } if (lastChanged < 0) - return; + return false; emit dataChanged(index(firstChanged), index(lastChanged), { NameRole, KindRole, @@ -174,6 +197,7 @@ void WalletAccountModel::applyPresentations( IsPrimaryRole, DefinitionIdRole, }); + return true; } void WalletAccountModel::setAlias(const QString& address, const QString& alias) diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h index 86acf4fa..8e21a585 100644 --- a/apps/shared/wallet/src/WalletAccountModel.h +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -51,7 +51,7 @@ class WalletAccountModel final : public QAbstractListModel { void replaceAccounts(const QVector& accounts, const QHash& aliases = {}, const QString& primaryAddress = {}); - void applyPresentations(const QVector& presentations); + bool applyPresentations(const QVector& presentations); void setAlias(const QString& address, const QString& alias); void setPrimaryAddress(const QString& address); bool contains(const QString& address) const; diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp index ddf915b6..9a64970d 100644 --- a/apps/shared/wallet/src/WalletController.cpp +++ b/apps/shared/wallet/src/WalletController.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include #include @@ -96,12 +98,56 @@ QString WalletController::defaultStoragePath() const return m_state.walletHome + QStringLiteral("/storage.json"); } +void WalletController::setDefaultSequencerAddress(const QString& address) +{ + const QString normalized = address.trimmed(); + const QUrl endpoint(normalized); + const QString scheme = endpoint.scheme().toLower(); + if (endpoint.isValid() + && !endpoint.host().isEmpty() + && (scheme == QStringLiteral("http") || scheme == QStringLiteral("https"))) { + m_defaultSequencerAddress = normalized; + } else { + m_defaultSequencerAddress.clear(); + } +} + +bool WalletController::seedDefaultWalletConfig(const QString& configPath) const +{ + if (m_defaultSequencerAddress.isEmpty() || QFileInfo::exists(configPath)) + return true; + + const QFileInfo configInfo(configPath); + if (!QDir().mkpath(configInfo.absolutePath())) { + qWarning() << "WalletController: failed to create wallet configuration directory"; + return false; + } + + QSaveFile config(configPath); + if (!config.open(QIODevice::WriteOnly)) { + qWarning() << "WalletController: failed to open wallet configuration"; + return false; + } + + const QByteArray contents = QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), m_defaultSequencerAddress }, + { QStringLiteral("seq_poll_timeout"), QStringLiteral("12s") }, + { QStringLiteral("seq_tx_poll_max_blocks"), 5 }, + { QStringLiteral("seq_poll_max_retries"), 5 }, + { QStringLiteral("seq_block_poll_max_amount"), 100 }, + }).toJson(QJsonDocument::Compact); + if (config.write(contents) != contents.size() || !config.commit()) { + qWarning() << "WalletController: failed to save wallet configuration"; + return false; + } + return true; +} + void WalletController::start() { if (m_started) return; m_started = true; - m_reachabilityTimer->start(); QTimer::singleShot(0, this, &WalletController::openOnStartup); } @@ -141,73 +187,102 @@ bool WalletController::beginOpen(const QString& config, const QString& storage) emit stateChanged(); } }); + const QPointer guard(this); m_wallet.connectAsync({ config, storage }, - [this, generation, config, storage](WalletSession session) { - if (generation != m_operationGeneration) + [guard, generation, config, storage](WalletSession session) { + if (!guard || generation != guard->m_operationGeneration) return; if (session.failure == WalletFailure::WalletMissing) { - m_state.syncStatus = QStringLiteral("closed"); - m_state.walletExists = false; - emit stateChanged(); + guard->m_state.syncStatus = QStringLiteral("closed"); + guard->m_state.walletExists = false; + emit guard->stateChanged(); return; } if (!session.ok()) { qWarning() << "WalletController: wallet connection failed" << walletFailureCode(session.failure); - m_state.syncStatus = QStringLiteral("error"); - m_state.syncError = walletFailureCode(session.failure); - emit stateChanged(); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(session.failure); + emit guard->stateChanged(); return; } - m_state.configPath = config; - m_state.storagePath = storage; - m_state.walletExists = QFileInfo::exists(storage) || session.adopted; - m_state.isWalletOpen = true; - m_state.syncStatus = QStringLiteral("ready"); - applySnapshot(session.snapshot); + guard->m_state.configPath = config; + guard->m_state.storagePath = storage; + guard->m_state.walletExists = QFileInfo::exists(storage) || session.adopted; + guard->m_state.isWalletOpen = true; + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(session.snapshot); }); return true; } QString WalletController::createDefaultWallet(const QString& password) { - return createWallet(defaultConfigPath(), defaultStoragePath(), password); + const QString config = defaultConfigPath(); + if (!seedDefaultWalletConfig(config)) + return {}; + return createWallet(config, defaultStoragePath(), password); } QString WalletController::createWallet(const QString& configPath, const QString& storagePath, const QString& password) { + const quint64 generation = ++m_operationGeneration; const QString config = toLocalPath(configPath); const QString storage = toLocalPath(storagePath); const WalletCreation creation = m_wallet.createWallet( { config, storage }, password); - const bool createdButUnreadable = creation.failure == WalletFailure::ReadFailed; - if (creation.mnemonic.isEmpty() - || (!creation.ok() && !createdButUnreadable)) { + if (creation.mnemonic.isEmpty()) { qWarning() << "WalletController: wallet creation failed" << walletFailureCode(creation.failure); return {}; } + stopReachability(); m_state.configPath = config; m_state.storagePath = storage; - m_state.walletExists = true; QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); - m_state.isWalletOpen = true; - if (!creation.snapshot.ok()) { - qWarning() << "WalletController: wallet creation refresh failed" - << walletFailureCode(creation.snapshot.failure); + if (!creation.ok()) { + qWarning() << "WalletController: wallet creation failed" + << walletFailureCode(creation.failure); + m_state.walletExists = QFileInfo::exists(storage); + m_state.isWalletOpen = false; m_state.syncStatus = QStringLiteral("error"); - m_state.syncError = walletFailureCode(creation.snapshot.failure); + m_state.syncError = walletFailureCode(creation.failure); emit stateChanged(); return creation.mnemonic; } - m_state.syncStatus = QStringLiteral("ready"); + m_state.walletExists = true; + m_state.isWalletOpen = true; + m_state.syncStatus = QStringLiteral("syncing"); m_state.syncError.clear(); - applySnapshot(creation.snapshot); + m_accountModel->replaceAccounts({}); + emit stateChanged(); + + const QPointer guard(this); + QTimer::singleShot(0, this, [guard, generation]() { + if (!guard || generation != guard->m_operationGeneration) + return; + guard->m_wallet.snapshotAsync(true, + [guard, generation](WalletSnapshot snapshot) { + if (!guard || generation != guard->m_operationGeneration) + return; + if (snapshot.ok()) { + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(snapshot); + return; + } + + qWarning() << "WalletController: initial wallet sync failed" + << walletFailureCode(snapshot.failure); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(snapshot.failure); + emit guard->stateChanged(); + }); + }); return creation.mnemonic; } @@ -224,14 +299,11 @@ bool WalletController::open() void WalletController::disconnect() { ++m_operationGeneration; + stopReachability(); m_wallet.disconnect(); m_state.isWalletOpen = false; m_state.syncStatus = QStringLiteral("closed"); m_state.syncError.clear(); - m_state.primaryAccountAddress.clear(); - m_state.primaryAccountName.clear(); - m_snapshot = {}; - m_aliases.clear(); m_accountModel->replaceAccounts({}); QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); emit stateChanged(); @@ -270,14 +342,22 @@ bool WalletController::setPrimaryAccount(const QString& address) void WalletController::applyAccountPresentations( const QVector& presentations) { - m_accountModel->applyPresentations(presentations); + if (!m_accountModel->applyPresentations(presentations)) + return; + + const QString previousPrimary = m_state.primaryAccountAddress; + const QString previousPrimaryName = m_state.primaryAccountName; QString primary = m_state.primaryAccountAddress; if (!m_accountModel->canBePrimary(primary)) primary = m_accountModel->firstAutomaticPrimary(); m_accountModel->setPrimaryAddress(primary); - storePrimaryAccount(primary); + if (primary != previousPrimary) + storePrimaryAccount(primary); updatePrimaryState(primary); - emit stateChanged(); + if (m_state.primaryAccountAddress != previousPrimary + || m_state.primaryAccountName != previousPrimaryName) { + emit stateChanged(); + } } QString WalletController::createAccount(bool isPublic) @@ -310,18 +390,19 @@ void WalletController::refresh() m_state.syncStatus = QStringLiteral("syncing"); m_state.syncError.clear(); emit stateChanged(); - m_wallet.snapshotAsync(true, [this, generation](WalletSnapshot next) { - if (generation != m_operationGeneration) + const QPointer guard(this); + m_wallet.snapshotAsync(true, [guard, generation](WalletSnapshot next) { + if (!guard || generation != guard->m_operationGeneration) return; if (next.ok()) { - m_state.syncStatus = QStringLiteral("ready"); - applySnapshot(next); + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(next); } else { qWarning() << "WalletController: wallet refresh failed" << walletFailureCode(next.failure); - m_state.syncStatus = QStringLiteral("error"); - m_state.syncError = walletFailureCode(next.failure); - emit stateChanged(); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(next.failure); + emit guard->stateChanged(); } }); } @@ -353,6 +434,8 @@ void WalletController::applySnapshot(const WalletSnapshot& snapshot) m_state.sequencerAddress = snapshot.sequencerAddress; emit snapshotChanged(); emit stateChanged(); + if (!m_reachabilityTimer->isActive()) + m_reachabilityTimer->start(); checkReachability(); } @@ -417,16 +500,44 @@ void WalletController::updatePrimaryState(const QString& address) } } +void WalletController::stopReachability() +{ + m_reachabilityTimer->stop(); + ++m_reachabilityGeneration; + if (m_reachabilityReply) { + QNetworkReply* reply = m_reachabilityReply; + m_reachabilityReply = nullptr; + m_reachabilityEndpoint.clear(); + reply->abort(); + } +} + void WalletController::checkReachability() { if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty()) return; - QNetworkRequest request{QUrl(m_state.sequencerAddress)}; + const QString endpoint = m_state.sequencerAddress; + if (m_reachabilityReply && endpoint == m_reachabilityEndpoint) + return; + + const quint64 generation = ++m_reachabilityGeneration; + if (m_reachabilityReply) + m_reachabilityReply->abort(); + QNetworkRequest request{QUrl(endpoint)}; request.setTransferTimeout(4000); QNetworkReply* reply = m_network->get(request); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - if (!m_state.isWalletOpen) { + m_reachabilityReply = reply; + m_reachabilityEndpoint = endpoint; + connect(reply, &QNetworkReply::finished, this, + [this, reply, generation, endpoint]() { + if (m_reachabilityReply == reply) { + m_reachabilityReply = nullptr; + m_reachabilityEndpoint.clear(); + } + if (!m_state.isWalletOpen + || generation != m_reachabilityGeneration + || endpoint != m_state.sequencerAddress) { reply->deleteLater(); return; } diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h index 6d8ae82f..e30f733e 100644 --- a/apps/shared/wallet/src/WalletController.h +++ b/apps/shared/wallet/src/WalletController.h @@ -8,6 +8,7 @@ #include "WalletProvider.h" class QNetworkAccessManager; +class QNetworkReply; class QTimer; class WalletAccountModel; struct WalletAccountPresentation; @@ -48,6 +49,7 @@ class WalletController final : public QObject { const WalletSnapshot& snapshot() const { return m_snapshot; } void start(); + void setDefaultSequencerAddress(const QString& address); QString createAccount(bool isPublic); void refresh(); QString balance(const QString& accountId, bool isPublic); @@ -70,11 +72,13 @@ class WalletController final : public QObject { static QString defaultWalletHome(); QString defaultConfigPath() const; QString defaultStoragePath() const; + bool seedDefaultWalletConfig(const QString& configPath) const; void openOnStartup(); bool beginOpen(const QString& config, const QString& storage); void applySnapshot(const WalletSnapshot& snapshot); void checkReachability(); + void stopReachability(); QString walletSettingsGroup() const; QHash loadAliases() const; QString loadPrimaryAccount() const; @@ -89,7 +93,11 @@ class WalletController final : public QObject { QHash m_aliases; WalletAccountModel* m_accountModel; QNetworkAccessManager* m_network; + QNetworkReply* m_reachabilityReply = nullptr; + QString m_reachabilityEndpoint; + QString m_defaultSequencerAddress; QTimer* m_reachabilityTimer; bool m_started = false; quint64 m_operationGeneration = 0; + quint64 m_reachabilityGeneration = 0; }; diff --git a/apps/shared/wallet/src/WalletProvider.h b/apps/shared/wallet/src/WalletProvider.h index 94fb2258..434b5635 100644 --- a/apps/shared/wallet/src/WalletProvider.h +++ b/apps/shared/wallet/src/WalletProvider.h @@ -99,6 +99,8 @@ class WalletProvider { using SessionCallback = std::function; using SnapshotCallback = std::function; using AccountReadsCallback = std::function)>; + using AccountCreationCallback = std::function; + using SubmissionCallback = std::function; virtual ~WalletProvider() = default; @@ -110,10 +112,13 @@ class WalletProvider { virtual void snapshotAsync(bool forceRefresh, SnapshotCallback callback) = 0; virtual void clearSnapshot() = 0; virtual WalletAccountCreation createAccount(bool isPublic) = 0; + virtual void createAccountAsync(bool isPublic, AccountCreationCallback callback) = 0; virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0; virtual void readPublicAccountsAsync(const QStringList& accountIds, AccountReadsCallback callback) = 0; virtual WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) = 0; + virtual void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) = 0; virtual void disconnect() = 0; }; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index b9cf4176..43d839f0 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -1,13 +1,20 @@ +#include #include #include #include +#include #include #include #include +#include +#include #include #include #include +#include +#include + #include "FakeWalletProvider.h" #include "LogosWalletProvider.h" #include "WalletAccountId.h" @@ -22,6 +29,30 @@ const QString ACCOUNT_C(64, QLatin1Char('d')); const QString PROGRAM_ID(64, QLatin1Char('c')); const QString EOA_OWNER(64, QLatin1Char('0')); +class ScopedEnvironment final { +public: + ScopedEnvironment(QByteArray name, QByteArray value) + : m_name(std::move(name)), + m_hadValue(qEnvironmentVariableIsSet(m_name.constData())), + m_previous(qgetenv(m_name.constData())) + { + qputenv(m_name.constData(), value); + } + + ~ScopedEnvironment() + { + if (m_hadValue) + qputenv(m_name.constData(), m_previous); + else + qunsetenv(m_name.constData()); + } + +private: + QByteArray m_name; + bool m_hadValue; + QByteArray m_previous; +}; + QString publicAccountJson(const QString& owner = PROGRAM_ID, const QString& balance = QStringLiteral("01000000000000000000000000000000"), const QString& nonce = QString(32, QLatin1Char('0')), @@ -59,16 +90,28 @@ private slots: void fallsBackToBalanceWhenPublicReadFails(); void createsAndPersistsAccounts(); void preservesCreatedAccountWhenPublicReadFails(); - void preservesCreatedAccountWhenSnapshotRefreshFails(); + void createdAccountDoesNotRescanWallet(); void dispatchesExactGenericTransaction(); void rejectsInvalidSubmissionResponses(); - void encodesAccountIdsForDisplay(); + void walletMutationsUseAsyncSdk(); + void staleAsyncMutationCannotCrossSession(); + void destroyedProviderIgnoresLateMutation(); void exposesStableAccountModelRoles(); + void encodesAccountIdsForDisplay(); void persistsHumanizedWalletPreferences(); void fakeProviderImplementsConsumerContract(); void controllerOwnsUiWalletFlow(); - void controllerReportsCreationPersistenceAndRefreshFailures(); + void controllerSeparatesSnapshotsFromCosmeticState(); + void controllerOpenDoesNotWaitForWalletSync(); + void controllerCreationDoesNotWaitForWalletSync(); + void controllerSeedsDefaultWalletConfigWithConfiguredEndpoint(); + void controllerPreservesExistingDefaultWalletConfig(); void controllerStopsReachabilityChecksAfterDisconnect(); + void completedAsyncSnapshotReleasesCallback(); + void deferredCallbacksIgnoreDestroyedController(); + void newerReachabilityResultWins(); + void coalescesReachabilityChecksForSameEndpoint(); + void controllerReportsPartialWalletCreation(); }; void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() @@ -103,6 +146,7 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() const int listCalls = modules.logos_execution_zone.listCalls; const int readCalls = modules.logos_execution_zone.publicReadCalls; + const int saveCalls = modules.logos_execution_zone.saveCalls; QVERIFY(provider.snapshot().ok()); QCOMPARE(modules.logos_execution_zone.listCalls, listCalls); QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls); @@ -110,6 +154,7 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() QVERIFY(provider.snapshot(true).ok()); QVERIFY(modules.logos_execution_zone.listCalls > listCalls); QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls); + QCOMPARE(modules.logos_execution_zone.saveCalls, saveCalls); modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( PROGRAM_ID, QString(32, QLatin1Char('f'))); @@ -236,6 +281,9 @@ void LogosWalletProviderTest::createsAndPersistsWallet() QVERIFY(directory.isValid()); LogosModules modules; + modules.logos_execution_zone.currentBlockHeight = 12; + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); LogosWalletProvider provider(&modules); const WalletPaths paths { directory.filePath(QStringLiteral("config/wallet.json")), @@ -249,6 +297,9 @@ void LogosWalletProviderTest::createsAndPersistsWallet() QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage); QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret")); QVERIFY(modules.logos_execution_zone.saveCalls >= 1); + QCOMPARE(modules.logos_execution_zone.syncCalls, 0); + QCOMPARE(modules.logos_execution_zone.listCalls, 0); + QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0); LogosModules rejectedModules; rejectedModules.logos_execution_zone.mnemonic.clear(); @@ -351,7 +402,7 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenPublicReadFails() QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7")); } -void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails() +void LogosWalletProviderTest::createdAccountDoesNotRescanWallet() { LogosModules modules; modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); @@ -362,11 +413,13 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails() modules.logos_execution_zone.currentBlockHeight = 1; modules.logos_execution_zone.syncResult = 1; + const int syncCalls = modules.logos_execution_zone.syncCalls; const WalletAccountCreation creation = provider.createAccount(true); QVERIFY(creation.ok()); QCOMPARE(creation.accountId, ACCOUNT_A); - QCOMPARE(creation.snapshot.failure, WalletFailure::ReadFailed); + QVERIFY(creation.snapshot.ok()); + QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls); } void LogosWalletProviderTest::dispatchesExactGenericTransaction() @@ -427,6 +480,108 @@ void LogosWalletProviderTest::rejectsInvalidSubmissionResponses() WalletFailure::InvalidRequest); } +void LogosWalletProviderTest::walletMutationsUseAsyncSdk() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + modules.logos_execution_zone.transactionResponse = QStringLiteral( + R"({"success":true,"tx_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"})"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + modules.logos_execution_zone.deferPublicAccountCreation = true; + bool creationFinished = false; + WalletAccountCreation creation; + const int listCalls = modules.logos_execution_zone.listCalls; + const int syncCalls = modules.logos_execution_zone.syncCalls; + provider.createAccountAsync(true, [&](WalletAccountCreation result) { + creation = std::move(result); + creationFinished = true; + }); + QVERIFY(!creationFinished); + QVERIFY(modules.logos_execution_zone.pendingPublicAccountCreation); + modules.logos_execution_zone.finishPublicAccountCreation(); + QVERIFY(creationFinished); + QVERIFY(creation.ok()); + QVERIFY(creation.publicAccount.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QCOMPARE(creation.snapshot.accounts.size(), 1); + QCOMPARE(modules.logos_execution_zone.listCalls, listCalls); + QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls); + + WalletTransaction transaction { + PROGRAM_ID, + { ACCOUNT_A, ACCOUNT_B }, + { true, false }, + { 7, 0, 4294967295U }, + }; + modules.logos_execution_zone.deferSubmission = true; + bool submissionFinished = false; + WalletSubmission submission; + provider.submitPublicTransactionAsync( + transaction, [&](WalletSubmission result) { + submission = std::move(result); + submissionFinished = true; + }); + QVERIFY(!submissionFinished); + QCOMPARE(modules.logos_execution_zone.submittedProgramId, PROGRAM_ID); + QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds); + QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements, + QVariantList({ true, false })); + QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(), + QVariantList({ 7U, 0U, 4294967295U })); + modules.logos_execution_zone.finishSubmission(); + QVERIFY(submissionFinished); + QVERIFY(submission.accepted()); + QCOMPARE(submission.nativeHash, QString(64, QLatin1Char('a'))); +} + +void LogosWalletProviderTest::staleAsyncMutationCannotCrossSession() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + modules.logos_execution_zone.deferPublicAccountCreation = true; + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + int callbackCount = 0; + WalletAccountCreation creation; + provider.createAccountAsync(true, [&](WalletAccountCreation result) { + ++callbackCount; + creation = std::move(result); + }); + provider.disconnect(); + modules.logos_execution_zone.finishPublicAccountCreation(); + + QCOMPARE(callbackCount, 1); + QCOMPARE(creation.failure, WalletFailure::WalletUnavailable); + QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0); +} + +void LogosWalletProviderTest::destroyedProviderIgnoresLateMutation() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.transactionResponse = QStringLiteral( + R"({"success":true,"tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"); + modules.logos_execution_zone.deferSubmission = true; + int callbackCount = 0; + { + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + provider.submitPublicTransactionAsync( + { PROGRAM_ID, { ACCOUNT_A }, { true }, { 1 } }, + [&](WalletSubmission) { ++callbackCount; }); + } + + modules.logos_execution_zone.finishSubmission(); + QCOMPARE(callbackCount, 0); +} + void LogosWalletProviderTest::exposesStableAccountModelRoles() { WalletAccountModel model; @@ -459,7 +614,7 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() QVERIFY(!model.data(model.index(2), WalletAccountModel::CanBePrimaryRole).toBool()); QSignalSpy presentationsChanged(&model, &QAbstractItemModel::dataChanged); - model.applyPresentations({ + const QVector presentations { { ACCOUNT_A, QStringLiteral("program"), @@ -470,7 +625,7 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() false, }, { - ACCOUNT_C, + walletAccountIdToBase58(ACCOUNT_C), QStringLiteral("token_holding"), QStringLiteral("TEST holding"), QStringLiteral("Token"), @@ -478,7 +633,8 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() ACCOUNT_A, true, }, - }); + }; + model.applyPresentations(presentations); QCOMPARE(presentationsChanged.count(), 1); QCOMPARE(model.data(model.index(2), WalletAccountModel::SectionRole).toString(), QStringLiteral("hidden")); @@ -490,6 +646,10 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() model.setAlias(ACCOUNT_C, {}); QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), QStringLiteral("TEST holding")); + + QSignalSpy redundantPresentation(&model, &QAbstractItemModel::dataChanged); + QVERIFY(!model.applyPresentations(presentations)); + QCOMPARE(redundantPresentation.count(), 0); } void LogosWalletProviderTest::encodesAccountIdsForDisplay() @@ -497,9 +657,16 @@ void LogosWalletProviderTest::encodesAccountIdsForDisplay() QCOMPARE(walletAccountIdToBase58( QStringLiteral("00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb")), QStringLiteral("14tAtixMByFyJrcZVyWibitnijLgd59PfyrjdnYzo8La")); + QCOMPARE(walletAccountIdFromBase58( + QStringLiteral("14tAtixMByFyJrcZVyWibitnijLgd59PfyrjdnYzo8La")), + QStringLiteral("00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb")); QCOMPARE(walletAccountIdToBase58(QString(64, QLatin1Char('0'))), QString(32, QLatin1Char('1'))); + QCOMPARE(walletAccountIdFromBase58(QString(32, QLatin1Char('1'))), + QString(64, QLatin1Char('0'))); QVERIFY(walletAccountIdToBase58(QStringLiteral("not-an-account-id")).isEmpty()); + QVERIFY(walletAccountIdFromBase58(QString(32, QLatin1Char('0'))).isEmpty()); + QVERIFY(walletAccountIdFromBase58(QString(45, QLatin1Char('1'))).isEmpty()); } void LogosWalletProviderTest::persistsHumanizedWalletPreferences() @@ -599,62 +766,161 @@ void LogosWalletProviderTest::controllerOwnsUiWalletFlow() settings.clear(); } -void LogosWalletProviderTest::controllerReportsCreationPersistenceAndRefreshFailures() +void LogosWalletProviderTest::controllerSeparatesSnapshotsFromCosmeticState() { - const QString settingsApplication = QStringLiteral("WalletCreationFailureTest"); + const QString settingsApplication = QStringLiteral("WalletSnapshotSignalTest"); QSettings settings(QStringLiteral("Logos"), settingsApplication); settings.clear(); FakeWalletProvider provider; + provider.connectResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true }, + }; + provider.snapshotResult = provider.connectResult.snapshot; WalletController controller(provider, settingsApplication); - const WalletUiState baseline = controller.state(); - const int baselineAccountCount = controller.accountModel()->count(); + QSignalSpy stateChanged(&controller, &WalletController::stateChanged); QSignalSpy snapshotChanged(&controller, &WalletController::snapshotChanged); - provider.createWalletResult.mnemonic = QStringLiteral("alpha beta gamma"); - provider.createWalletResult.failure = WalletFailure::SaveFailed; - provider.createWalletResult.snapshot.failure = WalletFailure::SaveFailed; - QCOMPARE(controller.createWallet(QStringLiteral("config"), QStringLiteral("storage"), - QStringLiteral("secret")), - QString()); - QCOMPARE(controller.state().isWalletOpen, baseline.isWalletOpen); - QCOMPARE(controller.state().walletExists, baseline.walletExists); - QCOMPARE(controller.state().syncStatus, baseline.syncStatus); - QCOMPARE(controller.state().syncError, baseline.syncError); - QCOMPARE(controller.accountModel()->count(), baselineAccountCount); + QVERIFY(controller.open()); + stateChanged.clear(); + snapshotChanged.clear(); + + QVERIFY(controller.setAccountAlias(ACCOUNT_A, QStringLiteral("Spending"))); + QCOMPARE(stateChanged.count(), 1); QCOMPARE(snapshotChanged.count(), 0); - provider.createWalletResult.failure = WalletFailure::ReadFailed; - provider.createWalletResult.snapshot.failure = WalletFailure::ReadFailed; - QCOMPARE(controller.createWallet(QStringLiteral("config"), QStringLiteral("storage"), - QStringLiteral("secret")), - QStringLiteral("alpha beta gamma")); + 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().walletExists); - QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); - QCOMPARE(controller.state().syncError, QStringLiteral("read_failed")); - QCOMPARE(controller.accountModel()->count(), baselineAccountCount); - QCOMPARE(snapshotChanged.count(), 0); + QVERIFY(controller.state().canSubmit()); + QCOMPARE(controller.state().syncStatus, QStringLiteral("ready")); + QCOMPARE(controller.accountModel()->count(), 1); + settings.clear(); +} - provider.createAccountResult.accountId = ACCOUNT_B; - provider.createAccountResult.snapshot.failure = WalletFailure::ReadFailed; - QCOMPARE(controller.createAccount(true), ACCOUNT_B); - QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); - QCOMPARE(controller.state().syncError, QStringLiteral("read_failed")); - QCOMPARE(controller.accountModel()->count(), baselineAccountCount); - QCOMPARE(snapshotChanged.count(), 0); +void LogosWalletProviderTest::controllerCreationDoesNotWaitForWalletSync() +{ + const QString settingsApplication = QStringLiteral("WalletAsyncCreationTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); - provider.createAccountResult.snapshot = {}; - provider.createAccountResult.snapshot.accounts = { - { ACCOUNT_A, QStringLiteral("5"), true, QStringLiteral("ok"), EOA_OWNER, {} }, - { ACCOUNT_B, QStringLiteral("3"), true, QStringLiteral("ok"), EOA_OWNER, {} }, + FakeWalletProvider provider; + provider.deferAsync = true; + provider.createWalletResult.mnemonic = QStringLiteral("one two three"); + provider.snapshotResult.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true }, }; - QCOMPARE(controller.createAccount(true), ACCOUNT_B); + 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().syncError.isEmpty()); - QCOMPARE(controller.accountModel()->count(), 2); - QCOMPARE(snapshotChanged.count(), 1); + QVERIFY(controller.state().canSubmit()); + QCOMPARE(controller.accountModel()->count(), 1); + settings.clear(); +} + +void LogosWalletProviderTest::controllerSeedsDefaultWalletConfigWithConfiguredEndpoint() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString walletHome = directory.filePath(QStringLiteral("wallet")); + ScopedEnvironment walletHomeEnvironment( + QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit()); + const QString settingsApplication = QStringLiteral("WalletDefaultEndpointTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + FakeWalletProvider provider; + provider.createWalletResult.mnemonic = QStringLiteral("one two three"); + WalletController controller(provider, settingsApplication); + controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/")); + + QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")), + provider.createWalletResult.mnemonic); + const QString configPath = walletHome + QStringLiteral("/wallet_config.json"); + QCOMPARE(provider.lastPaths.config, configPath); + + QFile config(configPath); + QVERIFY(config.open(QIODevice::ReadOnly)); + const QJsonDocument document = QJsonDocument::fromJson(config.readAll()); + QVERIFY(document.isObject()); + const QJsonObject values = document.object(); + QCOMPARE(values.value(QStringLiteral("sequencer_addr")).toString(), + QStringLiteral("https://testnet.lez.logos.co/")); + QCOMPARE(values.value(QStringLiteral("seq_poll_timeout")).toString(), + QStringLiteral("12s")); + QCOMPARE(values.value(QStringLiteral("seq_tx_poll_max_blocks")).toInt(), 5); + QCOMPARE(values.value(QStringLiteral("seq_poll_max_retries")).toInt(), 5); + QCOMPARE(values.value(QStringLiteral("seq_block_poll_max_amount")).toInt(), 100); + settings.clear(); +} + +void LogosWalletProviderTest::controllerPreservesExistingDefaultWalletConfig() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString walletHome = directory.filePath(QStringLiteral("wallet")); + QVERIFY(QDir().mkpath(walletHome)); + const QString configPath = walletHome + QStringLiteral("/wallet_config.json"); + const QByteArray existingConfig = QByteArrayLiteral( + "{\"sequencer_addr\":\"http://127.0.0.1:3040/\",\"custom\":true}"); + QFile config(configPath); + QVERIFY(config.open(QIODevice::WriteOnly)); + QCOMPARE(config.write(existingConfig), qint64(existingConfig.size())); + config.close(); + + ScopedEnvironment walletHomeEnvironment( + QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit()); + const QString settingsApplication = QStringLiteral("WalletExistingEndpointTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.createWalletResult.mnemonic = QStringLiteral("one two three"); + WalletController controller(provider, settingsApplication); + controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/")); + + QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")), + provider.createWalletResult.mnemonic); + QVERIFY(config.open(QIODevice::ReadOnly)); + QCOMPARE(config.readAll(), existingConfig); settings.clear(); } @@ -673,19 +939,164 @@ void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() QVERIFY(controller.open()); QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000); + auto* timer = controller.findChild(); + QVERIFY(timer); + QVERIFY(timer->isActive()); controller.disconnect(); + QVERIFY(!timer->isActive()); finished.clear(); - auto* timer = controller.findChild(); - QVERIFY(timer); timer->setInterval(1); controller.start(); QTest::qWait(50); + QVERIFY(!timer->isActive()); QCOMPARE(finished.count(), 0); settings.clear(); } +void LogosWalletProviderTest::completedAsyncSnapshotReleasesCallback() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + bool completed = false; + std::weak_ptr callbackLifetime; + { + auto lifetime = std::make_shared(1); + callbackLifetime = lifetime; + provider.snapshotAsync(true, + [lifetime = std::move(lifetime), &completed](WalletSnapshot snapshot) { + QVERIFY(snapshot.ok()); + completed = true; + }); + } + + QVERIFY(completed); + QVERIFY(callbackLifetime.expired()); + QCOMPARE(modules.logos_execution_zone.saveCalls, 0); +} + +void LogosWalletProviderTest::deferredCallbacksIgnoreDestroyedController() +{ + const QString settingsApplication = QStringLiteral("WalletDestroyedCallbackTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.deferAsync = true; + { + auto controller = std::make_unique(provider, settingsApplication); + QVERIFY(controller->open()); + } + provider.finishConnect(); + + provider.deferAsync = false; + { + auto controller = std::make_unique(provider, settingsApplication); + QVERIFY(controller->open()); + provider.deferAsync = true; + controller->refresh(); + } + provider.finishSnapshot(); + settings.clear(); +} + +void LogosWalletProviderTest::newerReachabilityResultWins() +{ + const QString settingsApplication = QStringLiteral("WalletReachabilityOrderTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + QTcpServer firstServer; + QTcpServer secondServer; + QVERIFY(firstServer.listen(QHostAddress::LocalHost)); + QVERIFY(secondServer.listen(QHostAddress::LocalHost)); + const QString firstEndpoint = QStringLiteral("http://127.0.0.1:%1") + .arg(firstServer.serverPort()); + const QString secondEndpoint = QStringLiteral("http://127.0.0.1:%1") + .arg(secondServer.serverPort()); + + FakeWalletProvider provider; + provider.connectResult.snapshot.sequencerAddress = firstEndpoint; + WalletController controller(provider, settingsApplication); + auto* network = controller.findChild(); + QVERIFY(network); + QSignalSpy finished(network, &QNetworkAccessManager::finished); + + QVERIFY(controller.open()); + QTRY_VERIFY(firstServer.hasPendingConnections()); + QTcpSocket* first = firstServer.nextPendingConnection(); + QVERIFY(first); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.sequencerAddress = secondEndpoint; + QCOMPARE(controller.createAccount(true), ACCOUNT_B); + QTRY_VERIFY(secondServer.hasPendingConnections()); + QTcpSocket* second = secondServer.nextPendingConnection(); + QVERIFY(second); + + second->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + second->disconnectFromHost(); + QTRY_COMPARE(finished.size(), 1); + QVERIFY(controller.state().sequencerReachable); + + first->disconnectFromHost(); + QTRY_COMPARE(finished.size(), 2); + QVERIFY(controller.state().sequencerReachable); + settings.clear(); +} + +void LogosWalletProviderTest::coalescesReachabilityChecksForSameEndpoint() +{ + const QString settingsApplication = QStringLiteral("WalletReachabilityCoalesceTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + QTcpServer server; + QVERIFY(server.listen(QHostAddress::LocalHost)); + const QString endpoint = QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort()); + + FakeWalletProvider provider; + provider.connectResult.snapshot.sequencerAddress = endpoint; + WalletController controller(provider, settingsApplication); + QVERIFY(controller.open()); + QTRY_VERIFY(server.hasPendingConnections()); + QTcpSocket* request = server.nextPendingConnection(); + QVERIFY(request); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.sequencerAddress = endpoint; + QCOMPARE(controller.createAccount(true), ACCOUNT_B); + QTest::qWait(50); + QVERIFY(!server.hasPendingConnections()); + + request->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + request->disconnectFromHost(); + settings.clear(); +} + +void LogosWalletProviderTest::controllerReportsPartialWalletCreation() +{ + const QString settingsApplication = QStringLiteral("WalletPartialCreationTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.createWalletResult.mnemonic = QStringLiteral("one two three"); + provider.createWalletResult.failure = WalletFailure::SaveFailed; + WalletController controller(provider, settingsApplication); + + QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")), + provider.createWalletResult.mnemonic); + QVERIFY(!controller.state().isWalletOpen); + QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); + QCOMPARE(controller.state().syncError, QStringLiteral("save_failed")); + settings.clear(); +} + QTEST_GUILESS_MAIN(LogosWalletProviderTest) #include "LogosWalletProviderTest.moc" diff --git a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h index 13ca5d77..87cf30f4 100644 --- a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h +++ b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h @@ -7,6 +7,7 @@ #include #include +#include class LogosAPI; @@ -32,6 +33,10 @@ class FakeExecutionZone { int listCalls = 0; int publicReadCalls = 0; int submitCalls = 0; + bool deferPublicAccountCreation = false; + bool deferSubmission = false; + std::function pendingPublicAccountCreation; + std::function pendingSubmission; QString openedConfig; QString openedStorage; QString createdConfig; @@ -77,6 +82,17 @@ class FakeExecutionZone { QString create_account_public() { return publicAccountId; } QString create_account_private() { return privateAccountId; } + void create_account_publicAsync(std::function callback) + { + if (deferPublicAccountCreation) + pendingPublicAccountCreation = std::move(callback); + else + callback(create_account_public()); + } + void create_account_privateAsync(std::function callback) + { + callback(create_account_private()); + } int get_last_synced_block() const { return lastSyncedBlock; } int get_current_block_height() const { return currentBlockHeight; } @@ -150,6 +166,38 @@ class FakeExecutionZone { submittedProgramId = programId; return transactionResponse; } + + void send_generic_public_transactionAsync( + const QStringList& accountIds, + const QVariantList& signingRequirements, + const QVariant& instruction, + const QString& programId, + std::function callback) + { + ++submitCalls; + submittedAccountIds = accountIds; + submittedSigningRequirements = signingRequirements; + submittedInstruction = instruction; + submittedProgramId = programId; + if (deferSubmission) + pendingSubmission = std::move(callback); + else + callback(transactionResponse); + } + + void finishPublicAccountCreation() + { + auto callback = std::move(pendingPublicAccountCreation); + if (callback) + callback(publicAccountId); + } + + void finishSubmission() + { + auto callback = std::move(pendingSubmission); + if (callback) + callback(transactionResponse); + } }; struct LogosModules { diff --git a/apps/shared/wallet/tests/qml/tst_CopyButton.qml b/apps/shared/wallet/tests/qml/tst_CopyButton.qml new file mode 100644 index 00000000..32c9aba9 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_CopyButton.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtTest + +import Logos.Wallet as Wallet + +Item { + id: root + + width: 360 + height: 240 + + Component { + id: copyButtonComponent + + Wallet.CopyButton {} + } + + Component { + id: clipboardSinkComponent + + TextEdit {} + } + + SignalSpy { + id: copyRequestedSpy + } + + TestCase { + name: "CopyButton" + when: windowShown + + function test_copiesTextAndRetainsCopySignal() { + const value = "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE" + const copyButton = createTemporaryObject(copyButtonComponent, root, { + "copyText": value, + "copyLabel": "Copy address" + }) + const sink = createTemporaryObject(clipboardSinkComponent, root) + verify(copyButton, "Copy button exists") + verify(sink, "Clipboard sink exists") + compare(copyButton.implicitWidth, 36) + compare(copyButton.implicitHeight, 36) + + copyRequestedSpy.target = copyButton + copyRequestedSpy.signalName = "copyRequested" + copyRequestedSpy.clear() + copyButton.click() + + verify(copyButton.copied) + compare(copyRequestedSpy.count, 1) + sink.paste() + tryCompare(sink, "text", value) + copyRequestedSpy.target = null + } + } +} diff --git a/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml index 898a2450..01768e80 100644 --- a/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml +++ b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml @@ -103,6 +103,38 @@ Item { tryCompare(dialog, "opened", false) } + function test_activityStateDoesNotBlockCancellation() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + dialog.openWithSnapshot({ amount: "5" }) + tryCompare(dialog, "opened", true) + + dialog.activityBusy = true + const cancelButton = findChild(dialog, "transactionCancelButton") + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(cancelButton.enabled) + verify(!confirmButton.enabled) + + dialog.cancel() + tryCompare(dialog, "opened", false) + } + + function test_cancelUsesTheConfirmationButtonShape() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + dialog.roundedCancelButton = true + dialog.openWithSnapshot({ amount: "5" }) + tryCompare(dialog, "opened", true) + + const cancelButtonLoader = findChild(dialog, "transactionCancelButtonLoader") + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(cancelButtonLoader) + tryVerify(function() { + return cancelButtonLoader.item + && cancelButtonLoader.item.background.radius === confirmButton.background.radius + }) + } + function test_keepsActionsInsideShortViewport() { const viewport = createTemporaryObject(viewportComponent, root) verify(viewport, "Short viewport exists") diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml index 3a07e347..71f480b2 100644 --- a/apps/shared/wallet/tests/qml/tst_WalletControl.qml +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -20,6 +20,7 @@ Item { property string walletHome: "/wallet" property string walletSyncStatus: "closed" property string walletSyncError: "" + property bool deferOpen: false property int openCalls: 0 property int createCalls: 0 property int publicAccountCalls: 0 @@ -37,9 +38,11 @@ Item { function openExisting() { openCalls++ - if (completeOpenImmediately) { - walletSyncStatus = "ready" + if (deferOpen) { + walletSyncStatus = "opening" + } else { isWalletOpen = true + walletSyncStatus = "ready" } return true } @@ -192,79 +195,17 @@ Item { tryCompare(fixture.control, "connected", true) } - function test_disablesConnectWhileWalletIsOpening() { - const fixture = createControl({ - walletExists: true, - walletSyncStatus: "syncing" - }, []) - const connectButton = findChild(fixture.control, "walletConnectButton") - verify(!connectButton.enabled) - compare(connectButton.text, "Connecting…") - mouseClick(connectButton) - compare(fixture.backend.openCalls, 0) - } - - function test_showsAsyncOpenFailure() { - const fixture = createControl({ - walletExists: true, - completeOpenImmediately: false - }, []) - const connectButton = findChild(fixture.control, "walletConnectButton") - mouseClick(connectButton) + function test_surfacesDeferredOpenFailure() { + const fixture = createControl({ walletExists: true, deferOpen: true }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) compare(fixture.backend.openCalls, 1) - verify(fixture.control.busy) + compare(fixture.control.syncStatus, "opening") fixture.backend.walletSyncStatus = "error" fixture.backend.walletSyncError = "open_failed" - - const dialog = findChild(fixture.control, "walletMessageDialog") - tryCompare(dialog, "opened", true) - compare(fixture.control.busy, false) - compare(dialog.message, "Wallet could not be opened: open_failed") - } - - function test_showsAsyncMissingWallet() { - const fixture = createControl({ - walletExists: true, - completeOpenImmediately: false - }, []) - mouseClick(findChild(fixture.control, "walletConnectButton")) - verify(fixture.control.busy) - - fixture.backend.walletExists = false - - const dialog = findChild(fixture.control, "walletMessageDialog") - tryCompare(dialog, "opened", true) - compare(fixture.control.busy, false) - compare(dialog.message, "Wallet could not be opened.") - } - - function test_showsStartupOpenFailure() { - const fixture = createControl({ - walletExists: true, - walletSyncStatus: "error", - walletSyncError: "open_failed" - }, []) const dialog = findChild(fixture.control, "walletMessageDialog") tryCompare(dialog, "opened", true) - compare(dialog.message, "Wallet could not be opened: open_failed") - } - - function test_cancellingWalletCreationDoesNotClaimSuccess() { - const fixture = createControl({ walletExists: false }, []) - mouseClick(findChild(fixture.control, "walletConnectButton")) - const creation = findChild(fixture.control, "createWalletDialog") - tryCompare(creation, "opened", true) - - fixture.backend.walletSyncStatus = "error" - fixture.backend.walletSyncError = "wallet_unavailable" - const message = findChild(fixture.control, "walletMessageDialog") - tryCompare(message, "opened", true) - compare(message.message, "Wallet could not be opened: wallet_unavailable") - - creation.close() - wait(0) - compare(message.message, "Wallet could not be opened: wallet_unavailable") + verify(dialog.message.includes("open_failed")) } function test_requiresSeedBackupAcknowledgement() { @@ -357,6 +298,31 @@ Item { tryCompare(fixture.control, "connected", false) } + function test_waitsForPrimaryDelegateBeforeShowingAccountType() { + const address = "a".repeat(64) + const fixture = createControl({ + isWalletOpen: true, + primaryAccountAddress: address, + primaryAccountName: "Primary" + }, []) + mouseClick(findChild(fixture.control, "walletAccountButton")) + + const accountType = findChild(fixture.control, "walletPrimaryAccountType") + verify(accountType, "Primary account type exists") + verify(!accountType.visible, "Account type waits for its selected delegate") + + fixture.model.append(accountData({ + name: "Primary", + address: address, + balance: "10", + isPublic: true, + isPrimary: true + })) + tryCompare(fixture.control, "selectedAddress", address) + tryCompare(accountType, "visible", true) + compare(accountType.text, "Public user account") + } + function test_connectedButtonClosesOpenMenu() { const fixture = createControl({ isWalletOpen: true }, [ { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } @@ -370,6 +336,53 @@ Item { tryCompare(menu, "opened", false) } + function test_walletMenuClosesWithEscape() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + const accountButton = findChild(fixture.control, "walletAccountButton") + const menu = findChild(fixture.control, "walletMenu") + + mouseClick(accountButton) + tryCompare(menu, "opened", true) + keyClick(Qt.Key_Escape) + tryCompare(menu, "opened", false) + } + + function test_createAccountDialogOwnsKeyboardFocus() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + + const backButton = findChild(fixture.control, "walletAccountsBackButton") + const addButton = findChild(fixture.control, "walletAddAccountButton") + verify(backButton && addButton, "Account controls exist") + mouseClick(addButton) + + const dialog = findChild(fixture.control, "createAccountDialog") + const privateSwitch = findChild(dialog, "privateAccountSwitch") + tryCompare(dialog, "opened", true) + tryVerify(function() { return privateSwitch.activeFocus }) + for (let index = 0; index < 6; ++index) { + keyClick(Qt.Key_Tab) + verify(!backButton.activeFocus, "Focus remains inside the dialog") + } + keyClick(Qt.Key_Escape) + tryCompare(dialog, "opened", false) + } + + function test_walletMessageDialogClosesWithEscape() { + const fixture = createControl({ isWalletOpen: true }, []) + const dialog = findChild(fixture.control, "walletMessageDialog") + + dialog.open() + tryCompare(dialog, "opened", true) + keyClick(Qt.Key_Escape) + tryCompare(dialog, "opened", false) + } + function test_openMenuTracksControlMovement() { const fixture = createControl({ isWalletOpen: true }, [ { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } @@ -427,6 +440,62 @@ Item { compare(fixture.control.selectedDisplayAddress, "base58-two") } + function test_flatWalletActionsUseReadableForeground() { + const fixture = createControl({ + isWalletOpen: true, + assets: [{ + name: "Available", + balance: "0", + definitionId: "c".repeat(64), + displayDefinitionId: "base58-available", + status: "ready", + section: "available" + }] + }, [ + { + name: "One", + address: "a".repeat(64), + balance: "10", + isPublic: true, + isPrimary: true + }, + { + name: "Two", + address: "b".repeat(64), + balance: "20", + isPublic: true + } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + + const available = findChild(fixture.control, "walletAvailableAssetsButton") + tryVerify(function() { return available && available.visible }) + compare(available.flat, true) + compare(available.palette.windowText, "#d4d4d8") + compare(available.contentItem.color, "#d4d4d8") + mouseClick(available) + tryCompare(fixture.control, "availableExpanded", true) + + mouseClick(findChild(fixture.control, "walletAccountsButton")) + const advanced = findChild(fixture.control, "walletAdvancedAccountsButton") + tryVerify(function() { return advanced && advanced.visible }) + compare(advanced.flat, true) + compare(advanced.palette.windowText, "#d4d4d8") + compare(advanced.contentItem.color, "#d4d4d8") + + const accountList = findChild(fixture.control, "walletAccountList") + tryVerify(function() { return accountList.itemAtIndex(1) !== null }) + const secondAccount = accountList.itemAtIndex(1) + const rename = findChild(secondAccount, "walletRenameButton") + const makePrimary = findChild(secondAccount, "walletMakePrimaryButton") + verify(rename && makePrimary, "Account action buttons exist") + for (const action of [rename, makePrimary]) { + compare(action.flat, true) + compare(action.palette.windowText, "#d4d4d8") + compare(action.contentItem.color, "#d4d4d8") + } + } + function test_accountNavigationKeepsOverviewInsidePopup() { const assets = [] for (let index = 0; index < 10; ++index) { diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h index 1beb6716..e86720ae 100644 --- a/apps/shared/wallet/tests/support/FakeWalletProvider.h +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -35,6 +35,9 @@ class FakeWalletProvider final : public WalletProvider { AccountReadsCallback callback; }; QVector pendingPublicAccountReads; + bool deferAsync = false; + SessionCallback pendingConnectCallback; + SnapshotCallback pendingSnapshotCallback; WalletSession connect(const WalletPaths& paths) override { @@ -47,7 +50,10 @@ class FakeWalletProvider final : public WalletProvider { { ++connectCalls; lastPaths = paths; - callback(connectResult); + if (deferAsync) + pendingConnectCallback = std::move(callback); + else + callback(connectResult); } WalletCreation createWallet(const WalletPaths& paths, @@ -69,7 +75,10 @@ class FakeWalletProvider final : public WalletProvider { { ++snapshotCalls; lastForceRefresh = forceRefresh; - callback(snapshotResult); + if (deferAsync) + pendingSnapshotCallback = std::move(callback); + else + callback(snapshotResult); } void clearSnapshot() override { ++clearCalls; } @@ -81,6 +90,11 @@ class FakeWalletProvider final : public WalletProvider { return createAccountResult; } + void createAccountAsync(bool isPublic, AccountCreationCallback callback) override + { + callback(createAccount(isPublic)); + } + WalletAccountRead readPublicAccount(const QString& accountId) const override { ++readCalls; @@ -117,8 +131,28 @@ class FakeWalletProvider final : public WalletProvider { return submissionResult; } + void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) override + { + callback(submitPublicTransaction(transaction)); + } + void disconnect() override { ++disconnectCalls; } + void finishConnect() + { + SessionCallback callback = std::move(pendingConnectCallback); + if (callback) + callback(connectResult); + } + + void finishSnapshot() + { + SnapshotCallback callback = std::move(pendingSnapshotCallback); + if (callback) + callback(snapshotResult); + } + private: QVector accountReads(const QStringList& accountIds) const { From 5c151728adad94f6f3b06c781baa68e594a412a1 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Sat, 18 Jul 2026 19:43:46 -0300 Subject: [PATCH 13/14] fix(wallet): present tokens in bordered boxes --- apps/shared/wallet/qml/WalletControl.qml | 24 ++++++-- .../wallet/tests/qml/tst_WalletControl.qml | 55 +++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml index 1536781d..daec4a6f 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -588,17 +588,23 @@ Item { color: "#a1a1aa" } Repeater { + objectName: "walletAssetRepeater" model: root.walletAssets delegate: Rectangle { required property var modelData + objectName: "walletAssetBox" Layout.fillWidth: true visible: modelData.section === "assets" - implicitHeight: visible ? 62 : 0 + implicitHeight: visible ? 68 : 0 color: "#27272a" - radius: 8 + radius: 10 + border.width: 1 + border.color: "#3f3f46" + Accessible.name: qsTr("%1 token, balance %2") + .arg(modelData.name).arg(modelData.balance) RowLayout { anchors.fill: parent - anchors.margins: 10 + anchors.margins: 12 ColumnLayout { Layout.fillWidth: true spacing: 1 @@ -650,17 +656,23 @@ Item { onClicked: root.availableExpanded = !root.availableExpanded } Repeater { + objectName: "walletAvailableAssetRepeater" model: root.walletAssets delegate: Rectangle { required property var modelData + objectName: "walletAvailableAssetBox" Layout.fillWidth: true visible: root.availableExpanded && modelData.section === "available" - implicitHeight: visible ? 58 : 0 + implicitHeight: visible ? 64 : 0 color: "#202023" - radius: 8 + radius: 10 + border.width: 1 + border.color: "#3f3f46" + Accessible.name: qsTr("%1 token, no balance") + .arg(modelData.name) RowLayout { anchors.fill: parent - anchors.margins: 10 + anchors.margins: 12 ColumnLayout { Layout.fillWidth: true spacing: 1 diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml index 71f480b2..6a73ebb1 100644 --- a/apps/shared/wallet/tests/qml/tst_WalletControl.qml +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -496,6 +496,61 @@ Item { } } + function test_tokenAssetsRenderInBoxes() { + const fixture = createControl({ + isWalletOpen: true, + assets: [ + { + name: "Held token", + balance: "42", + definitionId: "c".repeat(64), + displayDefinitionId: "base58-held-token", + status: "ready", + section: "assets" + }, + { + name: "Available token", + balance: "0", + definitionId: "d".repeat(64), + displayDefinitionId: "base58-available-token", + status: "ready", + section: "available" + } + ] + }, []) + mouseClick(findChild(fixture.control, "walletAccountButton")) + + const heldRepeater = findChild(fixture.control, "walletAssetRepeater") + verify(heldRepeater, "Held token repeater exists") + let held = null + tryVerify(function() { + held = heldRepeater.itemAt(0) + return held !== null + }) + verify(held, "Held token box exists") + tryCompare(held, "visible", true) + compare(held.implicitHeight, 68) + compare(held.radius, 10) + compare(held.border.width, 1) + compare(held.border.color, "#3f3f46") + + const availableRepeater = findChild(fixture.control, "walletAvailableAssetRepeater") + verify(availableRepeater, "Available token repeater exists") + let available = null + tryVerify(function() { + available = availableRepeater.itemAt(1) + return available !== null + }) + verify(available, "Available token box exists") + compare(available.visible, false) + mouseClick(findChild(fixture.control, "walletAvailableAssetsButton")) + tryCompare(available, "visible", true) + compare(available.implicitHeight, 64) + compare(available.radius, 10) + compare(available.border.width, 1) + compare(available.border.color, "#3f3f46") + } + function test_accountNavigationKeepsOverviewInsidePopup() { const assets = [] for (let index = 0; index < 10; ++index) { From 2977399cb05630cd6dbc7529f71617e0a1413805 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Sat, 18 Jul 2026 19:53:02 -0300 Subject: [PATCH 14/14] fix(wallet): show decoded program account data --- apps/amm/src/AmmUiBackend.cpp | 15 +++++++ apps/shared/wallet/qml/WalletControl.qml | 2 + .../wallet/qml/internal/AccountDelegate.qml | 42 ++++++++++++++++++- apps/shared/wallet/src/WalletAccountModel.cpp | 6 +++ apps/shared/wallet/src/WalletAccountModel.h | 3 ++ .../tests/cpp/LogosWalletProviderTest.cpp | 5 +++ .../wallet/tests/qml/tst_WalletControl.qml | 32 ++++++++++++++ 7 files changed, 103 insertions(+), 2 deletions(-) diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 53ed616a..17590829 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -95,6 +95,19 @@ 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 {}; +} + WalletAccountRead accountRead(const WalletAccount& account) { WalletAccountRead read; @@ -444,6 +457,8 @@ void AmmUiBackend::applyWalletPortfolio(quint64 generation) presentation.address = account.id; presentation.programName = program.programName; presentation.accountType = account.typeName; + if (account.status == QStringLiteral("decoded")) + presentation.decodedData = decodedDataText(account.value); if (program.programId == m_tokenProgramId && account.typeName == QStringLiteral("TokenHolding")) { const QJsonObject fungible = enumFields( diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml index daec4a6f..9167dc1c 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -757,6 +757,7 @@ Item { required property string section required property string programName required property string accountType + required property string decodedData required property string visibility required property bool canBePrimary required property bool isPrimary @@ -786,6 +787,7 @@ Item { section: accountWrapper.section programName: accountWrapper.programName accountType: accountWrapper.accountType + decodedData: accountWrapper.decodedData visibility: accountWrapper.visibility canBePrimary: accountWrapper.canBePrimary isPrimary: accountWrapper.isPrimary diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml index ea5da2ba..43a2f211 100644 --- a/apps/shared/wallet/qml/internal/AccountDelegate.qml +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -16,6 +16,7 @@ ItemDelegate { required property string section required property string programName required property string accountType + required property string decodedData required property string visibility required property bool canBePrimary required property bool isPrimary @@ -87,14 +88,51 @@ ItemDelegate { } Label { - visible: root.programName.length > 0 + objectName: "walletProgramName" + visible: root.section === "advanced" && root.programName.length > 0 Layout.fillWidth: true - text: qsTr("%1 program · wallet controlled").arg(root.programName) + text: qsTr("Program: %1").arg(root.programName) color: "#a1a1aa" font.pixelSize: 11 elide: Text.ElideRight } + ColumnLayout { + visible: root.section === "advanced" && root.decodedData.length > 0 + Layout.fillWidth: true + spacing: 4 + + Label { + objectName: "walletDecodedDataLabel" + text: qsTr("Decoded data") + color: "#a1a1aa" + font.pixelSize: 11 + } + + Rectangle { + objectName: "walletDecodedDataBox" + Layout.fillWidth: true + implicitHeight: decodedDataText.implicitHeight + 16 + color: "#18181b" + radius: 6 + border.width: 1 + border.color: "#3f3f46" + + Text { + id: decodedDataText + objectName: "walletDecodedData" + anchors.fill: parent + anchors.margins: 8 + text: root.decodedData + color: "#d4d4d8" + font.family: "monospace" + font.pixelSize: 10 + textFormat: Text.PlainText + wrapMode: Text.WrapAnywhere + } + } + } + RowLayout { Layout.fillWidth: true spacing: 4 diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp index 8b93327a..f7bdfcad 100644 --- a/apps/shared/wallet/src/WalletAccountModel.cpp +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -59,6 +59,8 @@ QVariant WalletAccountModel::data(const QModelIndex& index, int role) const return account.definitionId; case AliasRole: return account.alias; + case DecodedDataRole: + return account.decodedData; default: return {}; } @@ -84,6 +86,7 @@ QHash WalletAccountModel::roleNames() const { DefinitionIdRole, "definitionId" }, { AliasRole, "alias" }, { DisplayAddressRole, "displayAddress" }, + { DecodedDataRole, "decodedData" }, }; } @@ -154,6 +157,7 @@ bool WalletAccountModel::applyPresentations( entry.programName = presentation.programName; entry.accountType = presentation.accountType; entry.definitionId = presentation.definitionId; + entry.decodedData = presentation.decodedData; entry.semanticName = presentation.semanticName; entry.section = sectionFor(entry, presentation.hiddenFromAccounts); entry.canBePrimary = entry.kind == QStringLiteral("user") @@ -175,6 +179,7 @@ bool WalletAccountModel::applyPresentations( && entry.programName == current.programName && entry.accountType == current.accountType && entry.definitionId == current.definitionId + && entry.decodedData == current.decodedData && entry.canBePrimary == current.canBePrimary && entry.isPrimary == current.isPrimary) { continue; @@ -193,6 +198,7 @@ bool WalletAccountModel::applyPresentations( SectionRole, ProgramNameRole, AccountTypeRole, + DecodedDataRole, CanBePrimaryRole, IsPrimaryRole, DefinitionIdRole, diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h index 8e21a585..7eb3f2c0 100644 --- a/apps/shared/wallet/src/WalletAccountModel.h +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -14,6 +14,7 @@ struct WalletAccountPresentation { QString accountType; QString definitionId; bool hiddenFromAccounts = false; + QString decodedData; }; class WalletAccountModel final : public QAbstractListModel { @@ -39,6 +40,7 @@ class WalletAccountModel final : public QAbstractListModel { DefinitionIdRole, AliasRole, DisplayAddressRole, + DecodedDataRole, }; Q_ENUM(Role) @@ -79,6 +81,7 @@ class WalletAccountModel final : public QAbstractListModel { QString programName; QString accountType; QString definitionId; + QString decodedData; bool canBePrimary = false; bool isPrimary = false; }; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index 43d839f0..9fdc6aa6 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -604,6 +604,8 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B); QCOMPARE(model.roleNames().value(WalletAccountModel::DisplayAddressRole), QByteArray("displayAddress")); + QCOMPARE(model.roleNames().value(WalletAccountModel::DecodedDataRole), + QByteArray("decodedData")); QCOMPARE(model.data(model.index(1), WalletAccountModel::DisplayAddressRole).toString(), walletAccountIdToBase58(ACCOUNT_B)); QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(), @@ -623,6 +625,7 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() QStringLiteral("UserAccount"), {}, false, + QStringLiteral("{\"public_key\":\"Public/test\"}"), }, { walletAccountIdToBase58(ACCOUNT_C), @@ -640,6 +643,8 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() QStringLiteral("hidden")); QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), QStringLiteral("TEST holding")); + QCOMPARE(model.data(model.index(0), WalletAccountModel::DecodedDataRole).toString(), + QStringLiteral("{\"public_key\":\"Public/test\"}")); model.setAlias(ACCOUNT_C, QStringLiteral("Reserve")); QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), QStringLiteral("Reserve")); diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml index 6a73ebb1..88cba849 100644 --- a/apps/shared/wallet/tests/qml/tst_WalletControl.qml +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -180,6 +180,7 @@ Item { section: account.section || "accounts", programName: account.programName || "", accountType: account.accountType || "", + decodedData: account.decodedData || "", visibility: account.visibility || (account.isPublic === false ? "private" : "public"), canBePrimary: account.canBePrimary === undefined ? true : account.canBePrimary, isPrimary: account.isPrimary === true @@ -622,6 +623,37 @@ Item { compare(fixture.control.selectedAddress, userAddress) } + function test_advancedShowsProgramAndDecodedData() { + const decodedData = "{\n \"name\": \"Test token\"\n}" + const fixture = createControl({ isWalletOpen: true }, [ + { + name: "Token definition", + address: "c".repeat(64), + balance: "0", + isPublic: true, + kind: "token_definition", + section: "advanced", + programName: "Token", + accountType: "TokenDefinition", + decodedData: decodedData, + canBePrimary: false + } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + mouseClick(findChild(fixture.control, "walletAdvancedAccountsButton")) + + const list = findChild(fixture.control, "walletAccountList") + tryVerify(function() { return list.itemAtIndex(0) !== null }) + const program = findChild(list.itemAtIndex(0), "walletProgramName") + const decoded = findChild(list.itemAtIndex(0), "walletDecodedData") + verify(program && decoded, "Advanced details exist") + tryCompare(program, "visible", true) + compare(program.text, "Program: Token") + tryCompare(decoded, "visible", true) + compare(decoded.text, decodedData) + } + function test_onlyProgramRecordsLeavesPrimaryEmpty() { const fixture = createControl({ isWalletOpen: true }, [{ name: "Token definition",