feat: improve private account listing

This commit is contained in:
Sergio Chouhy 2026-07-24 20:51:16 -03:00
parent f726dada5f
commit d31b5becd3
4 changed files with 132 additions and 24 deletions

View File

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

View File

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

View File

@ -9,6 +9,7 @@
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
@ -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.");

View File

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