diff --git a/src/LEZWalletAccountModel.cpp b/src/LEZWalletAccountModel.cpp index e7c096b..8674b20 100644 --- a/src/LEZWalletAccountModel.cpp +++ b/src/LEZWalletAccountModel.cpp @@ -31,6 +31,7 @@ QVariant LEZWalletAccountModel::data(const QModelIndex& index, int role) const case SectionKeyRole: return e.sectionKey; case KeysJsonRole: return e.keysJson; case IsFirstInGroupRole: return e.isFirstInGroup; + case IsFirstPrivateRole: return e.isFirstPrivate; case IsInitializedRole: return e.isInitialized; default: return QVariant(); } @@ -47,6 +48,7 @@ QHash LEZWalletAccountModel::roleNames() const { SectionKeyRole, "sectionKey" }, { KeysJsonRole, "keysJson" }, { IsFirstInGroupRole, "isFirstInGroup" }, + { IsFirstPrivateRole, "isFirstPrivate" }, { IsInitializedRole, "isInitialized" } }; } @@ -100,8 +102,13 @@ void LEZWalletAccountModel::replaceFromVariantList(const QVariantList& list) if (a.isPublic != b.isPublic) return a.isPublic; return a.sectionKey < b.sectionKey; }); - for (int i = 0; i < m_entries.size(); ++i) + for (int i = 0; i < m_entries.size(); ++i) { m_entries[i].isFirstInGroup = (i == 0) || (m_entries[i].sectionKey != m_entries[i - 1].sectionKey); + // All private key-groups sit under a single "Private" title (unlike the public + // section, they don't each get their own top-level header) — so only the very + // first private row across all groups needs to flag it. + m_entries[i].isFirstPrivate = !m_entries[i].isPublic && (i == 0 || m_entries[i - 1].isPublic); + } endResetModel(); if (oldCount != m_entries.size()) emit countChanged(); diff --git a/src/LEZWalletAccountModel.h b/src/LEZWalletAccountModel.h index 6291172..3edf386 100644 --- a/src/LEZWalletAccountModel.h +++ b/src/LEZWalletAccountModel.h @@ -18,7 +18,8 @@ struct LEZWalletAccountEntry { bool isPublic = true; QString sectionKey; QString keysJson; // {nullifier_public_key, viewing_public_key} shared by the whole section; private only - bool isFirstInGroup = false; // QML renders the section header above rows where this is true + bool isFirstInGroup = false; // QML renders the per-key-set header (copy button) above rows where this is true + bool isFirstPrivate = false; // QML renders the single "Private" section title above rows where this is true // Whether some program (in practice, the authenticated-transfer program) has claimed // this account yet. Defaults to false (shown as needing init) so an account whose // state we failed to enrich isn't silently mistaken for a usable one. @@ -42,6 +43,7 @@ public: SectionKeyRole, KeysJsonRole, IsFirstInGroupRole, + IsFirstPrivateRole, IsInitializedRole }; Q_ENUM(Role) diff --git a/src/LEZWalletBackend.cpp b/src/LEZWalletBackend.cpp index 71eb7f6..ee0fb41 100644 --- a/src/LEZWalletBackend.cpp +++ b/src/LEZWalletBackend.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -59,6 +60,13 @@ namespace { return p; } + // lez_core has no UI-facing concept of a statistics file; derive one deterministically + // next to the storage file so onboarding doesn't need a third path picker. + QString statisticsPathFor(const QString& localStoragePath) { + const QFileInfo info(localStoragePath); + return info.absolutePath() + QStringLiteral("/statistics.json"); + } + // An account is uninitialized until some program claims it (program_owner goes // from all-zero to that program's ID) — see DEFAULT_PROGRAM_ID in the execution // zone's state machine. Accounts this wallet creates are only ever claimed by the @@ -166,7 +174,7 @@ void LEZWalletBackend::openIfPathsConfigured(int attempt) qDebug() << "LEZWalletBackend: opening wallet with config" << configPath() << "storage" << storagePath(); - int err = m_logos->lez_core.open(configPath(), storagePath()); + int err = m_logos->lez_core.open(configPath(), storagePath(), statisticsPathFor(storagePath())); if (err == WALLET_FFI_SUCCESS) { qDebug() << "LEZWalletBackend: wallet opened successfully"; finishOpeningWallet(); @@ -501,7 +509,10 @@ void LEZWalletBackend::applySequencerAddrToConfig(const QString& configPath, con obj[QStringLiteral("seq_poll_max_retries")] = 10; obj[QStringLiteral("seq_block_poll_max_amount")] = 100; } - obj[QStringLiteral("sequencer_addr")] = sequencerAddr; + QJsonObject sequencerEntry; + sequencerEntry[QStringLiteral("sequencer_addr")] = sequencerAddr; + sequencerEntry[QStringLiteral("basic_auth")] = QJsonValue::Null; + obj[QStringLiteral("sequencers")] = QJsonArray{ sequencerEntry }; QDir().mkpath(QFileInfo(configPath).absolutePath()); if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) @@ -517,7 +528,7 @@ QString LEZWalletBackend::createNew(QString configPath, QString storagePath, QSt // 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. if (QFile::exists(localConfigPath) && QFile::exists(localStoragePath)) { - int err = m_logos->lez_core.open(localConfigPath, localStoragePath); + int err = m_logos->lez_core.open(localConfigPath, localStoragePath, statisticsPathFor(localStoragePath)); if (err != WALLET_FFI_SUCCESS) { return QStringLiteral( "Could not load the wallet at the selected paths. Pick " @@ -534,7 +545,7 @@ QString LEZWalletBackend::createNew(QString configPath, QString storagePath, QSt applySequencerAddrToConfig(localConfigPath, sequencerAddr); const QString mnemonic = m_logos->lez_core.create_new( - localConfigPath, localStoragePath, password); + localConfigPath, localStoragePath, statisticsPathFor(localStoragePath), password); if (mnemonic.isEmpty()) return QStringLiteral("Failed to create wallet. Check paths and try again."); diff --git a/src/qml/views/AccountsPanel.qml b/src/qml/views/AccountsPanel.qml index 8d3bca0..b3c4af1 100644 --- a/src/qml/views/AccountsPanel.qml +++ b/src/qml/views/AccountsPanel.qml @@ -139,36 +139,124 @@ Rectangle { width: listView.width spacing: Theme.spacing.small - // keysJson is only ever read inside the copy button's click handler below, - // never bound to anything visible — so unlike accountId/isPublic/isFirstInGroup - // (which are warmed up by their visible bindings), the Qt Remote Objects model - // replica never requests this role from the source process until something - // actually reads it. Without this binding, the first click reads a not-yet- - // fetched (empty) value and the real data only arrives in time for the next - // click. Referencing it here forces the role to be requested as soon as the - // row is created. - property string keysJsonWarm: model.keysJson ?? "" - + // "Public Accounts" title: the public section is a single group, so this + // is equivalent to showing it once above the first public row. RowLayout { Layout.fillWidth: true - visible: model.isFirstInGroup ?? false + visible: model.isPublic && (model.isFirstInGroup ?? false) spacing: Theme.spacing.small LogosText { - text: model.isPublic - ? qsTr("Public Accounts") - : qsTr("Private") - font.pixelSize: Theme.typography.secondaryText + text: qsTr("Public Accounts") + font.pixelSize: Theme.typography.primaryText font.bold: true - color: Theme.palette.textSecondary + color: Theme.palette.text + } + } + + // "Private Accounts" title: shown once above the whole private section, + // unlike the per-key-set row below which repeats for every private key + // group. Wrapped the same way as the "Public Accounts" title above so + // both line up identically. + RowLayout { + Layout.fillWidth: true + visible: model.isFirstPrivate ?? false + spacing: Theme.spacing.small + + LogosText { + text: qsTr("Private Accounts") + font.pixelSize: Theme.typography.primaryText + font.bold: true + color: Theme.palette.text + } + } + + // Per-key-set row: separates each private key group within the Private + // section, naming the group by its Npk/Vpk pair, and holds the copy + // button for that pair. + RowLayout { + id: keyGroupHeader + Layout.fillWidth: true + visible: !model.isPublic && (model.isFirstInGroup ?? false) + spacing: Theme.spacing.small + + property var groupKeys: { + try { return JSON.parse(model.keysJson ?? "{}") } catch (e) { return {} } } - Item { Layout.fillWidth: true } + function shortKey(key) { + return key && key.length > 12 ? key.slice(0, 6) + "…" + key.slice(-4) : (key || "") + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + LogosText { + Layout.fillWidth: true + text: qsTr("Accounts under keys") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + + // Each of Npk/Vpk gets its own bullet, aligned with "Private + // Accounts"/"Accounts under keys" above. Labels share a fixed + // width (the wider of the two) so the value column still lines + // up between the two rows. + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + LogosText { + text: "•" + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + LogosText { + id: npkLabel + Layout.preferredWidth: Math.max(npkLabel.implicitWidth, vpkLabel.implicitWidth) + text: qsTr("Npk:") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + LogosText { + Layout.fillWidth: true + text: keyGroupHeader.shortKey(keyGroupHeader.groupKeys.nullifier_public_key) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + elide: Text.ElideRight + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + LogosText { + text: "•" + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + LogosText { + id: vpkLabel + Layout.preferredWidth: Math.max(npkLabel.implicitWidth, vpkLabel.implicitWidth) + text: qsTr("Vpk:") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + LogosText { + Layout.fillWidth: true + text: keyGroupHeader.shortKey(keyGroupHeader.groupKeys.viewing_public_key) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + elide: Text.ElideRight + } + } + } LogosCopyButton { Layout.preferredHeight: 32 Layout.preferredWidth: 32 - visible: !model.isPublic icon.color: Theme.palette.textMuted onCopyText: root.copyRequested(model.keysJson ?? "") }