diff --git a/src/LEZWalletAccountModel.cpp b/src/LEZWalletAccountModel.cpp index fd1b584..3869cd6 100644 --- a/src/LEZWalletAccountModel.cpp +++ b/src/LEZWalletAccountModel.cpp @@ -1,5 +1,6 @@ #include "LEZWalletAccountModel.h" #include +#include LEZWalletAccountModel::LEZWalletAccountModel(QObject* parent) : QAbstractListModel(parent) @@ -24,6 +25,9 @@ QVariant LEZWalletAccountModel::data(const QModelIndex& index, int role) const case AccountIdRole: return e.accountId; case BalanceRole: return e.balance; case IsPublicRole: return e.isPublic; + case SectionKeyRole: return e.sectionKey; + case KeysJsonRole: return e.keysJson; + case IsFirstInGroupRole: return e.isFirstInGroup; default: return QVariant(); } } @@ -34,7 +38,10 @@ QHash LEZWalletAccountModel::roleNames() const { NameRole, "name" }, { AccountIdRole, "accountId" }, { BalanceRole, "balance" }, - { IsPublicRole, "isPublic" } + { IsPublicRole, "isPublic" }, + { SectionKeyRole, "sectionKey" }, + { KeysJsonRole, "keysJson" }, + { IsFirstInGroupRole, "isFirstInGroup" } }; } @@ -51,12 +58,28 @@ void LEZWalletAccountModel::replaceFromVariantList(const QVariantList& list) const QVariantMap map = v.toMap(); e.accountId = map.value(QStringLiteral("account_id")).toString(); e.isPublic = map.value(QStringLiteral("is_public"), true).toBool(); + if (e.isPublic) { + e.sectionKey = PublicSectionKey; + } else { + e.sectionKey = map.value(QStringLiteral("npk")).toString(); + e.keysJson = map.value(QStringLiteral("keys_json")).toString(); + } } else { e.accountId = v.toString(); e.isPublic = true; + e.sectionKey = PublicSectionKey; } m_entries.append(e); } + // Keep entries grouped by section (public first) so consecutive rows of the same + // group are contiguous, then mark each group's first row for the QML header. + std::stable_sort(m_entries.begin(), m_entries.end(), + [](const LEZWalletAccountEntry& a, const LEZWalletAccountEntry& b) { + if (a.isPublic != b.isPublic) return a.isPublic; + return a.sectionKey < b.sectionKey; + }); + for (int i = 0; i < m_entries.size(); ++i) + m_entries[i].isFirstInGroup = (i == 0) || (m_entries[i].sectionKey != m_entries[i - 1].sectionKey); endResetModel(); if (oldCount != m_entries.size()) emit countChanged(); diff --git a/src/LEZWalletAccountModel.h b/src/LEZWalletAccountModel.h index 0f59039..4be7d15 100644 --- a/src/LEZWalletAccountModel.h +++ b/src/LEZWalletAccountModel.h @@ -4,13 +4,25 @@ #include #include +// Public accounts have no key group, so they all share the PublicSectionKey section. +// Private accounts section by NPK (the key group they belong to) — see +// LEZWalletBackend::buildEnrichedAccountList, which attaches "npk"/"keys_json" per entry. +inline const QString PublicSectionKey = QStringLiteral("public"); + struct LEZWalletAccountEntry { QString name; QString accountId; QString balance; 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 }; +// Note: this model is exposed to QML via Qt Remote Objects model replication (see +// logos.model() in ExecutionZoneWalletView.qml), which only replicates roles — not +// arbitrary Q_PROPERTYs. Anything QML needs must be a role on the row, not a property +// on the model itself. class LEZWalletAccountModel : public QAbstractListModel { Q_OBJECT Q_PROPERTY(int count READ count NOTIFY countChanged) @@ -19,7 +31,10 @@ public: NameRole = Qt::UserRole + 1, AccountIdRole, BalanceRole, - IsPublicRole + IsPublicRole, + SectionKeyRole, + KeysJsonRole, + IsFirstInGroupRole }; Q_ENUM(Role) diff --git a/src/LEZWalletBackend.cpp b/src/LEZWalletBackend.cpp index 410c32f..a07f85d 100644 --- a/src/LEZWalletBackend.cpp +++ b/src/LEZWalletBackend.cpp @@ -133,11 +133,34 @@ void LEZWalletBackend::openIfPathsConfigured() } } +// Tags each private account with the NPK of the key group it belongs to (plus that +// group's {nullifier_public_key, viewing_public_key} JSON), so the model can section +// accounts by key group instead of listing them flat. Public accounts are untouched. +QVariantList LEZWalletBackend::buildEnrichedAccountList() +{ + QVariantList raw = m_logos->logos_execution_zone.list_accounts(); + QVariantList enriched; + enriched.reserve(raw.size()); + for (const QVariant& v : raw) { + QVariantMap map = v.toMap(); + if (!map.value(QStringLiteral("is_public"), true).toBool()) { + const QString accountId = map.value(QStringLiteral("account_id")).toString(); + const QString keysJson = getPrivateAccountKeys(accountId); + const QJsonDocument doc = QJsonDocument::fromJson(keysJson.toUtf8()); + if (doc.isObject()) { + map[QStringLiteral("npk")] = doc.object().value(QStringLiteral("nullifier_public_key")).toString(); + map[QStringLiteral("keys_json")] = keysJson; + } + } + enriched.append(map); + } + return enriched; +} + void LEZWalletBackend::finishOpeningWallet() { setIsWalletOpen(true); - QVariantList arr = m_logos->logos_execution_zone.list_accounts(); - m_accountModel->replaceFromVariantList(arr); + m_accountModel->replaceFromVariantList(buildEnrichedAccountList()); fetchAndUpdateBlockHeights(); startChunkedSync(); refreshSequencerAddr(); @@ -145,8 +168,7 @@ void LEZWalletBackend::finishOpeningWallet() void LEZWalletBackend::refreshAccounts() { - QVariantList arr = m_logos->logos_execution_zone.list_accounts(); - m_accountModel->replaceFromVariantList(arr); + m_accountModel->replaceFromVariantList(buildEnrichedAccountList()); fetchAndUpdateBlockHeights(); if (!m_syncing) startChunkedSync(); @@ -177,8 +199,7 @@ void LEZWalletBackend::syncNextChunk() m_syncing = false; // Sync may have discovered new private accounts (e.g. shielded transfers to a // foreign NPK/VPK); re-list so the model picks them up without a restart. - QVariantList arr = m_logos->logos_execution_zone.list_accounts(); - m_accountModel->replaceFromVariantList(arr); + m_accountModel->replaceFromVariantList(buildEnrichedAccountList()); fetchAndUpdateBlockHeights(); updateBalances(); return; diff --git a/src/LEZWalletBackend.h b/src/LEZWalletBackend.h index f886de9..126e1fd 100644 --- a/src/LEZWalletBackend.h +++ b/src/LEZWalletBackend.h @@ -57,6 +57,7 @@ private: void applySequencerAddrToConfig(const QString& configPath, const QString& sequencerAddr); void fetchAndUpdateBlockHeights(); void startChunkedSync(); + QVariantList buildEnrichedAccountList(); void updateBalances(); void refreshSequencerAddr(); diff --git a/src/qml/ExecutionZoneWalletView.qml b/src/qml/ExecutionZoneWalletView.qml index 9164d83..783a6d6 100644 --- a/src/qml/ExecutionZoneWalletView.qml +++ b/src/qml/ExecutionZoneWalletView.qml @@ -237,16 +237,6 @@ Rectangle { clipHelper.selectAll() clipHelper.copy() } - onCopyPublicKeysRequested: (accountIdHex) => { - if (!backend) return - logos.watch(backend.getPrivateAccountKeys(accountIdHex), - function(keys) { - clipHelper.text = keys - clipHelper.selectAll() - clipHelper.copy() - }, - function(error) { console.warn("getPrivateAccountKeys failed:", error) }) - } } } } diff --git a/src/qml/controls/AccountComboBox.qml b/src/qml/controls/AccountComboBox.qml index 8d42c27..5eba3d0 100644 --- a/src/qml/controls/AccountComboBox.qml +++ b/src/qml/controls/AccountComboBox.qml @@ -12,7 +12,6 @@ ComboBox { // Forwarded from AccountDelegate's copy button — bubble up to the parent // view, which calls backend.copyToClipboard(). signal copyRequested(string text) - signal copyPublicKeysRequested(string accountIdHex) leftPadding: 12 rightPadding: 12 @@ -62,7 +61,6 @@ ComboBox { width: root.popup ? (root.popup.width - root.popup.leftPadding - root.popup.rightPadding) : 368 highlighted: root.highlightedIndex === index onCopyRequested: (text) => root.copyRequested(text) - onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id) } popup: Popup { diff --git a/src/qml/controls/AccountDelegate.qml b/src/qml/controls/AccountDelegate.qml index 9c4be1f..6c96a40 100644 --- a/src/qml/controls/AccountDelegate.qml +++ b/src/qml/controls/AccountDelegate.qml @@ -13,8 +13,10 @@ ItemDelegate { // to backend.copyToClipboard(...) — AccountDelegate doesn't reach into // the global QML scope for `backend` since it now lives behind the // logos.module() bridge in the parent view. + // Public accounts only — private accounts copy their group's NPK/VPK via + // the section header instead, since an individual account ID isn't an + // address you'd hand out (only the (npk, vpk) group identity is). signal copyRequested(string text) - signal copyPublicKeysRequested(string accountIdHex) leftPadding: Theme.spacing.medium rightPadding: Theme.spacing.medium @@ -78,10 +80,8 @@ ItemDelegate { LogosCopyButton { Layout.preferredHeight: 40 Layout.preferredWidth: 40 - onCopyText: model.isPublic - ? root.copyRequested(Base58.encode(model.accountId ?? "")) - : root.copyPublicKeysRequested(model.accountId ?? "") - visible: addressLabel.text + onCopyText: root.copyRequested(Base58.encode(model.accountId ?? "")) + visible: addressLabel.text && model.isPublic icon.color: Theme.palette.textMuted } } diff --git a/src/qml/views/AccountsPanel.qml b/src/qml/views/AccountsPanel.qml index aefce03..9775fa9 100644 --- a/src/qml/views/AccountsPanel.qml +++ b/src/qml/views/AccountsPanel.qml @@ -7,6 +7,7 @@ import Logos.Controls // TODO: remove relative paths and use qmldir instead import "../controls" import "../popups" +import "../Base58.js" as Base58 Rectangle { id: root @@ -21,7 +22,6 @@ Rectangle { signal createPrivateAccountRequested() signal fetchBalancesRequested() signal copyRequested(string text) - signal copyPublicKeysRequested(string accountIdHex) radius: Theme.spacing.radiusXlarge color: Theme.palette.backgroundSecondary @@ -63,7 +63,7 @@ Rectangle { // Sync progress ColumnLayout { Layout.fillWidth: true - spacing: Theme.spacing.xsmall + spacing: Theme.spacing.small visible: root.currentBlockHeight > 0 && root.lastSyncedBlock < root.currentBlockHeight RowLayout { @@ -127,10 +127,44 @@ Rectangle { spacing: Theme.spacing.small model: root.accountModel - delegate: AccountDelegate { + // Each private account's "keysJson"/"sectionKey"/"isFirstInGroup" are plain + // model roles (replicated like any other row data), so the group header for + // the section a row starts is rendered inline here rather than via + // ListView.section — section.delegate only gets the section's string value, + // with no way back to that row's data once the model is a remote replica. + delegate: ColumnLayout { width: listView.width - onCopyRequested: (text) => root.copyRequested(text) - onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id) + spacing: Theme.spacing.small + + RowLayout { + Layout.fillWidth: true + visible: model.isFirstInGroup ?? false + spacing: Theme.spacing.small + + LogosText { + text: model.isPublic + ? qsTr("Public Accounts") + : qsTr("Private — %1").arg(Base58.encode(model.sectionKey ?? "").slice(0, 8)) + font.pixelSize: Theme.typography.secondaryText + font.bold: true + color: Theme.palette.textSecondary + } + + Item { Layout.fillWidth: true } + + LogosCopyButton { + Layout.preferredHeight: 32 + Layout.preferredWidth: 32 + visible: !model.isPublic + icon.color: Theme.palette.textMuted + onCopyText: root.copyRequested(model.keysJson ?? "") + } + } + + AccountDelegate { + Layout.fillWidth: true + onCopyRequested: (text) => root.copyRequested(text) + } } } diff --git a/src/qml/views/DashboardView.qml b/src/qml/views/DashboardView.qml index d412515..4cbff45 100644 --- a/src/qml/views/DashboardView.qml +++ b/src/qml/views/DashboardView.qml @@ -30,7 +30,6 @@ Rectangle { signal transferShieldedOwnedRequested(string fromAccountId, string toAccountId, string amount) signal transferDeshieldedRequested(string fromAccountId, string toAccountId, string amount) signal copyRequested(string copyText) - signal copyPublicKeysRequested(string accountIdHex) color: Theme.palette.background @@ -52,7 +51,6 @@ Rectangle { onCreatePrivateAccountRequested: root.createPrivateAccountRequested() onFetchBalancesRequested: root.fetchBalancesRequested() onCopyRequested: (text) => root.copyRequested(text) - onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id) } TransferPanel {