This commit is contained in:
Sergio Chouhy 2026-07-25 02:17:50 -03:00
parent 97bae8ad1f
commit 753a92fb81
6 changed files with 416 additions and 128 deletions

View File

@ -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());

View File

@ -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;

View File

@ -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))

View File

@ -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)
}
)
}
}
}

View File

@ -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()
}
}
}
}
}

View File

@ -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()
}
}
}
}
}