From 753a92fb810267eaeb7d79e8c84d8888f64f5d4f Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Sat, 25 Jul 2026 02:17:50 -0300 Subject: [PATCH] wip --- src/LEZWalletBackend.cpp | 71 +++++ src/LEZWalletBackend.h | 2 + src/LEZWalletBackend.rep | 3 + src/qml/ExecutionZoneWalletView.qml | 35 +++ src/qml/popups/MnemonicRevealDialog.qml | 94 +++++++ src/qml/views/OnboardingView.qml | 339 +++++++++++++++--------- 6 files changed, 416 insertions(+), 128 deletions(-) create mode 100644 src/qml/popups/MnemonicRevealDialog.qml diff --git a/src/LEZWalletBackend.cpp b/src/LEZWalletBackend.cpp index 7b4065f..e9c36d6 100644 --- a/src/LEZWalletBackend.cpp +++ b/src/LEZWalletBackend.cpp @@ -504,6 +504,10 @@ QString LEZWalletBackend::createNew(QString configPath, QString storagePath, QSt const QString localConfigPath = toLocalPath(configPath); const QString localStoragePath = toLocalPath(storagePath); + // Clear any mnemonic left over from a previous attempt in this session — + // only a freshly generated one (below) should ever be shown to the user. + setLastCreatedMnemonic(QString()); + // Both files already on disk: this is most likely an existing wallet the // user pointed us at (e.g. from the setup screen), not a request to // overwrite it. Try to load it instead of blindly creating a new one. @@ -524,11 +528,73 @@ QString LEZWalletBackend::createNew(QString configPath, QString storagePath, QSt if (!sequencerAddr.isEmpty()) applySequencerAddrToConfig(localConfigPath, sequencerAddr); + // create_new() writes storage.json directly to this path — unlike the + // config path (handled inside applySequencerAddrToConfig above), nothing + // else ensures its parent directory exists, so wallet_ffi_create_new() + // silently fails whenever storagePath points into a not-yet-created folder. + QDir().mkpath(QFileInfo(localStoragePath).absolutePath()); + const QString mnemonic = m_logos->lez_core.create_new( localConfigPath, localStoragePath, password); if (mnemonic.isEmpty()) return QStringLiteral("Failed to create wallet. Check paths and try again."); + // Surfaced to the QML layer via the lastCreatedMnemonic PROP so the setup + // screen can show a one-time "save your recovery phrase" prompt; cleared + // once the user acknowledges it (see clearLastCreatedMnemonic()). + setLastCreatedMnemonic(mnemonic); + + persistConfigPath(localConfigPath); + persistStoragePath(localStoragePath); + finishOpeningWallet(); + return QString(); +} + +QString LEZWalletBackend::restoreFromMnemonic(QString configPath, QString storagePath, QString mnemonic, QString password, QString sequencerAddr) +{ + // Depth of the key tree to reconstruct per keychain (public/private) when + // re-deriving from the mnemonic. NOT a linear BIP44-style gap limit — the + // wallet FFI builds an actual tree of 2^depth candidate keys at this depth + // and docs explicitly warn it "induces exponential growth in execution + // time". Keep this small; anything much bigger can take a very long time + // (or effectively hang) before accounts are found and the wallet opens. + constexpr int RESTORE_SCAN_DEPTH = 5; + + const QString localConfigPath = toLocalPath(configPath); + const QString localStoragePath = toLocalPath(storagePath); + + // The user already has this mnemonic (they just typed it in), so there's + // nothing new to show — and any leftover from a prior createNew() in this + // session shouldn't linger either. + setLastCreatedMnemonic(QString()); + + if (QFile::exists(localConfigPath) || QFile::exists(localStoragePath)) { + return QStringLiteral( + "A wallet already exists at the selected paths. Choose paths " + "that don't exist yet to restore a wallet there."); + } + + if (!sequencerAddr.isEmpty()) + applySequencerAddrToConfig(localConfigPath, sequencerAddr); + + // Same not-yet-created-folder gap as createNew() above — make sure the + // storage path's parent directory exists before create_new() tries to + // write storage.json there. + QDir().mkpath(QFileInfo(localStoragePath).absolutePath()); + + // restore_storage() needs an already-open wallet handle to restore into, + // so first create fresh (empty) storage at the chosen paths, then + // overwrite its key material by re-deriving from the given mnemonic. + const QString created = m_logos->lez_core.create_new( + localConfigPath, localStoragePath, password); + if (created.isEmpty()) + return QStringLiteral("Failed to initialize wallet storage. Check paths and try again."); + + const int err = m_logos->lez_core.restore_storage( + mnemonic.trimmed(), password, QVariant(RESTORE_SCAN_DEPTH)); + if (err != WALLET_FFI_SUCCESS) + return QStringLiteral("Failed to restore wallet. Check the recovery phrase and try again."); + persistConfigPath(localConfigPath); persistStoragePath(localStoragePath); finishOpeningWallet(); @@ -541,6 +607,11 @@ void LEZWalletBackend::copyToClipboard(QString text) QGuiApplication::clipboard()->setText(text); } +void LEZWalletBackend::clearLastCreatedMnemonic() +{ + setLastCreatedMnemonic(QString()); +} + bool LEZWalletBackend::checkLabelAvailable(QString label) { return m_logos->lez_core.check_label_available(label.trimmed()); diff --git a/src/LEZWalletBackend.h b/src/LEZWalletBackend.h index 5a0307c..bffb5af 100644 --- a/src/LEZWalletBackend.h +++ b/src/LEZWalletBackend.h @@ -53,7 +53,9 @@ public slots: void refreshVaultBalances() override; QString vaultClaim(QString fromHex, bool isPublic, QString amountStr) override; QString createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr) override; + QString restoreFromMnemonic(QString configPath, QString storagePath, QString mnemonic, QString password, QString sequencerAddr) override; void copyToClipboard(QString text) override; + void clearLastCreatedMnemonic() override; bool checkLabelAvailable(QString label) override; QString addLabel(QString label, QString accountIdHex, bool isPublic) override; diff --git a/src/LEZWalletBackend.rep b/src/LEZWalletBackend.rep index ae4060b..007390c 100644 --- a/src/LEZWalletBackend.rep +++ b/src/LEZWalletBackend.rep @@ -6,6 +6,7 @@ class LEZWalletBackend PROP(int lastSyncedBlock READONLY) PROP(int currentBlockHeight READONLY) PROP(QString sequencerAddr READONLY) + PROP(QString lastCreatedMnemonic READONLY) SLOT(QString createAccountPublic()) SLOT(QString createAccountPrivate()) @@ -30,8 +31,10 @@ class LEZWalletBackend SLOT(QString vaultClaim(QString fromHex, bool isPublic, QString amountStr)) SLOT(QString createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr)) + SLOT(QString restoreFromMnemonic(QString configPath, QString storagePath, QString mnemonic, QString password, QString sequencerAddr)) SLOT(void copyToClipboard(QString text)) + SLOT(void clearLastCreatedMnemonic()) SLOT(bool checkLabelAvailable(QString label)) SLOT(QString addLabel(QString label, QString accountIdHex, bool isPublic)) diff --git a/src/qml/ExecutionZoneWalletView.qml b/src/qml/ExecutionZoneWalletView.qml index 65472a4..258cc37 100644 --- a/src/qml/ExecutionZoneWalletView.qml +++ b/src/qml/ExecutionZoneWalletView.qml @@ -112,6 +112,27 @@ Rectangle { visible: false } + MnemonicRevealDialog { + id: mnemonicDialog + + onCopyRequested: (text) => { + clipHelper.text = text + clipHelper.selectAll() + clipHelper.copy() + } + onAcknowledged: if (backend) backend.clearLastCreatedMnemonic() + } + + Connections { + target: backend + function onLastCreatedMnemonicChanged() { + if (backend.lastCreatedMnemonic.length > 0) { + mnemonicDialog.mnemonic = backend.lastCreatedMnemonic + mnemonicDialog.open() + } + } + } + SetLabelDialog { id: setLabelDialog @@ -186,6 +207,20 @@ Rectangle { } ) } + onRecoverWallet: function(configPath, storagePath, mnemonic, password, sequencerUrl) { + if (!backend) return + // restoreFromMnemonic() returns an empty string on success, or a + // human-readable error message otherwise. + logos.watch(backend.restoreFromMnemonic(configPath, storagePath, mnemonic, password, sequencerUrl), + function(errorMessage) { + if (errorMessage) + createError = errorMessage + }, + function(error) { + createError = qsTr("Error restoring wallet: %1").arg(error) + } + ) + } } } diff --git a/src/qml/popups/MnemonicRevealDialog.qml b/src/qml/popups/MnemonicRevealDialog.qml new file mode 100644 index 0000000..c1eea47 --- /dev/null +++ b/src/qml/popups/MnemonicRevealDialog.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +import "../controls" + +// Shown right after a new wallet is created, so the user can reveal, copy, +// and write down their recovery phrase before it's cleared from memory. +// Hidden by default — the caller drives `mnemonic` and listens for +// `acknowledged()` to clear its backend-side copy once the user is done. +Popup { + id: root + + property string mnemonic: "" + property bool revealed: false + + signal copyRequested(string text) + signal acknowledged() + + modal: true + dim: true + padding: Theme.spacing.large + // No dismissal via Escape/outside click — the user must explicitly + // confirm they've saved the phrase via the Continue button below. + closePolicy: Popup.NoAutoClose + + anchors.centerIn: parent + + onOpened: root.revealed = false + + background: Rectangle { + color: Theme.palette.backgroundSecondary + radius: Theme.spacing.radiusXlarge + border.color: Theme.palette.backgroundElevated + } + + contentItem: ColumnLayout { + width: 360 + spacing: Theme.spacing.large + + LogosText { + text: qsTr("Save your recovery phrase") + font.pixelSize: Theme.typography.titleText + font.weight: Theme.typography.weightBold + color: Theme.palette.text + } + LogosText { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: qsTr("Write these words down in order and store them somewhere safe. Anyone with this phrase can access your funds, and it cannot be recovered if lost.") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + LogosTextArea { + id: mnemonicText + Layout.fillWidth: true + Layout.preferredHeight: 90 + readOnly: true + text: root.revealed ? root.mnemonic : "•••• ".repeat(6) + } + + LogosCopyButton { + Layout.alignment: Qt.AlignTop + onCopyText: root.copyRequested(root.mnemonic) + } + } + + FeedbackButton { + text: root.revealed ? qsTr("Hide phrase") : qsTr("Show phrase") + onClicked: root.revealed = !root.revealed + } + + RowLayout { + Layout.topMargin: Theme.spacing.medium + Layout.fillWidth: true + Item { Layout.fillWidth: true } + FeedbackButton { + text: qsTr("Continue") + onClicked: { + root.acknowledged() + root.close() + } + } + } + } +} diff --git a/src/qml/views/OnboardingView.qml b/src/qml/views/OnboardingView.qml index 7908729..59b6d61 100644 --- a/src/qml/views/OnboardingView.qml +++ b/src/qml/views/OnboardingView.qml @@ -14,12 +14,20 @@ Control { property string configPath: "" property string storePath: "" property string createError: "" + property bool recoverMode: false + // Create/restore is a blocking round-trip to the wallet backend (it can take + // a while, especially restoring), which freezes the UI thread for its + // duration — this at least shows a message right before that happens + // instead of the form just going inert with no explanation. + property bool busy: false signal createWallet(string configPath, string storagePath, string password, string sequencerUrl) + signal recoverWallet(string configPath, string storagePath, string mnemonic, string password, string sequencerUrl) readonly property string testnetUrl: "https://testnet.lez.logos.co" readonly property string localhostUrl: "http://127.0.0.1:3040" + onCreateErrorChanged: if (createError.length > 0) root.busy = false QtObject { id: d @@ -33,142 +41,217 @@ Control { } } - ColumnLayout { - id: cardColumn + // Fires the actual (blocking) request one tick after the button click, so + // the "Restoring…"/"Creating…" busy state above has a chance to actually + // render before the UI thread freezes for the call. + Timer { + id: submitTimer + interval: 50 + onTriggered: { + if (root.recoverMode) { + root.recoverWallet(configPathField.text, storagePathField.text, mnemonicField.text.trim(), passwordField.text, sequencerUrlField.text) + } else { + root.createWallet(configPathField.text, storagePathField.text, passwordField.text, sequencerUrlField.text) + } + } + } + // Scrollable so the form stays reachable (including the submit button) + // when its content is taller than the window, e.g. with the recovery + // phrase field shown — rather than silently clipping/overflowing. + LogosScrollView { + id: scrollView anchors.fill: parent - anchors.margins: Theme.spacing.xlarge - spacing: Theme.spacing.large - LogosText { - text: qsTr("Set up LEZ Wallet") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - LogosText { - text: qsTr("Configure storage and secure with a password.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - Layout.topMargin: -Theme.spacing.small - } + Item { + width: scrollView.availableWidth + implicitHeight: cardColumn.implicitHeight + 2 * Theme.spacing.xlarge - LogosText { - text: qsTr("Storage") - font.pixelSize: Theme.typography.secondaryText - font.weight: Theme.typography.weightMedium - color: Theme.palette.text - } - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - LogosTextField { - id: storagePathField - Layout.fillWidth: true - placeholderText: qsTr("Add store path") - text: root.storePath - } - FeedbackButton { - text: qsTr("Browse") - onClicked: storageFolderDialog.open() - } - } - LogosText { - text: qsTr("Config file") - font.pixelSize: Theme.typography.secondaryText - font.weight: Theme.typography.weightMedium - color: Theme.palette.text - } - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - LogosTextField { - id: configPathField - Layout.fillWidth: true - placeholderText: qsTr("Add path to config") - text: root.configPath - } - FeedbackButton { - Layout.preferredHeight: configPathField.height - text: qsTr("Browse") - onClicked: configFileDialog.open() - } - } + ColumnLayout { + id: cardColumn - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Theme.spacing.medium - spacing: Theme.spacing.small - LogosText { - text: qsTr("Network") - font.pixelSize: Theme.typography.secondaryText - font.weight: Theme.typography.weightMedium - color: Theme.palette.text - } - LogosTextField { - id: sequencerUrlField - Layout.fillWidth: true - placeholderText: qsTr("Sequencer URL") - text: root.testnetUrl - } - } - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - FeedbackButton { - text: qsTr("Testnet") - opacity: sequencerUrlField.text === root.testnetUrl ? 1.0 : 0.4 - onClicked: sequencerUrlField.text = root.testnetUrl - } - FeedbackButton { - text: qsTr("Localhost") - opacity: sequencerUrlField.text === root.localhostUrl ? 1.0 : 0.4 - onClicked: sequencerUrlField.text = root.localhostUrl - } - } + x: Theme.spacing.xlarge + y: Theme.spacing.xlarge + width: parent.width - 2 * Theme.spacing.xlarge + spacing: Theme.spacing.large - LogosText { - text: qsTr("Security") - font.pixelSize: Theme.typography.secondaryText - font.weight: Theme.typography.weightMedium - color: Theme.palette.text - Layout.topMargin: Theme.spacing.medium - } - LogosTextField { - id: passwordField - Layout.fillWidth: true - placeholderText: qsTr("Password") - echoMode: TextInput.Password - } - LogosTextField { - id: confirmField - Layout.fillWidth: true - placeholderText: qsTr("Confirm") - echoMode: TextInput.Password - } + LogosText { + text: qsTr("Set up LEZ Wallet") + font.pixelSize: Theme.typography.titleText + font.weight: Theme.typography.weightBold + color: Theme.palette.text + } + LogosText { + text: qsTr("Configure storage and secure with a password.") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + Layout.topMargin: -Theme.spacing.small + } - LogosText { - id: errorLabel - Layout.fillWidth: true - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.error - wrapMode: Text.WordWrap - visible: text.length > 0 - text: root.createError - } + LogosTabBar { + id: modeTabBar + Layout.fillWidth: true + currentIndex: root.recoverMode ? 1 : 0 + onCurrentIndexChanged: { + root.recoverMode = currentIndex === 1 + root.createError = "" + } - FeedbackButton { - Layout.alignment: Qt.AlignRight - text: qsTr("Create Wallet") - font.pixelSize: Theme.typography.secondaryText - onClicked: { - 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(configPathField.text, storagePathField.text, passwordField.text, sequencerUrlField.text) + LogosTabButton { text: qsTr("Create New") } + LogosTabButton { text: qsTr("Recover from Mnemonic") } + } + + LogosText { + text: qsTr("Storage") + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightMedium + color: Theme.palette.text + } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + LogosTextField { + id: storagePathField + Layout.fillWidth: true + placeholderText: qsTr("Add store path") + text: root.storePath + } + FeedbackButton { + text: qsTr("Browse") + onClicked: storageFolderDialog.open() + } + } + LogosText { + text: qsTr("Config file") + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightMedium + color: Theme.palette.text + } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + LogosTextField { + id: configPathField + Layout.fillWidth: true + placeholderText: qsTr("Add path to config") + text: root.configPath + } + FeedbackButton { + Layout.preferredHeight: configPathField.height + text: qsTr("Browse") + onClicked: configFileDialog.open() + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Theme.spacing.medium + spacing: Theme.spacing.small + LogosText { + text: qsTr("Network") + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightMedium + color: Theme.palette.text + } + LogosTextField { + id: sequencerUrlField + Layout.fillWidth: true + placeholderText: qsTr("Sequencer URL") + text: root.testnetUrl + } + } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + FeedbackButton { + text: qsTr("Testnet") + opacity: sequencerUrlField.text === root.testnetUrl ? 1.0 : 0.4 + onClicked: sequencerUrlField.text = root.testnetUrl + } + FeedbackButton { + text: qsTr("Localhost") + opacity: sequencerUrlField.text === root.localhostUrl ? 1.0 : 0.4 + onClicked: sequencerUrlField.text = root.localhostUrl + } + } + + LogosText { + text: qsTr("Recovery phrase") + visible: root.recoverMode + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightMedium + color: Theme.palette.text + Layout.topMargin: Theme.spacing.medium + } + LogosTextArea { + id: mnemonicField + visible: root.recoverMode + Layout.fillWidth: true + Layout.preferredHeight: 90 + placeholderText: qsTr("Enter your 12 or 24-word recovery phrase") + } + + LogosText { + text: qsTr("Security") + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightMedium + color: Theme.palette.text + Layout.topMargin: Theme.spacing.medium + } + LogosTextField { + id: passwordField + Layout.fillWidth: true + placeholderText: qsTr("Password") + echoMode: TextInput.Password + } + LogosTextField { + id: confirmField + Layout.fillWidth: true + placeholderText: qsTr("Confirm") + echoMode: TextInput.Password + } + + LogosText { + Layout.fillWidth: true + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + wrapMode: Text.WordWrap + visible: root.busy + text: root.recoverMode + ? qsTr("Restoring your wallet from the recovery phrase… this can take a little while. Please don't close the app.") + : qsTr("Creating your wallet…") + } + + LogosText { + id: errorLabel + Layout.fillWidth: true + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.error + wrapMode: Text.WordWrap + visible: !root.busy && text.length > 0 + text: root.createError + } + + FeedbackButton { + Layout.alignment: Qt.AlignRight + enabled: !root.busy + text: root.busy + ? (root.recoverMode ? qsTr("Restoring…") : qsTr("Creating…")) + : (root.recoverMode ? qsTr("Restore Wallet") : qsTr("Create Wallet")) + font.pixelSize: Theme.typography.secondaryText + onClicked: { + 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 if (root.recoverMode && mnemonicField.text.trim().length === 0) { + root.createError = qsTr("Recovery phrase cannot be empty.") + } else { + root.createError = "" + root.busy = true + submitTimer.start() + } + } } } }