diff --git a/src/LEZWalletAccountModel.cpp b/src/LEZWalletAccountModel.cpp index 308a1e8..4375ade 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 IsInitializedRole: return e.isInitialized; default: return QVariant(); } } @@ -45,7 +46,8 @@ QHash LEZWalletAccountModel::roleNames() const { IsPublicRole, "isPublic" }, { SectionKeyRole, "sectionKey" }, { KeysJsonRole, "keysJson" }, - { IsFirstInGroupRole, "isFirstInGroup" } + { IsFirstInGroupRole, "isFirstInGroup" }, + { IsInitializedRole, "isInitialized" } }; } @@ -71,6 +73,7 @@ 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(); + e.isInitialized = map.value(QStringLiteral("is_initialized"), false).toBool(); if (e.isPublic) { e.sectionKey = PublicSectionKey; } else { @@ -131,6 +134,20 @@ void LEZWalletAccountModel::setVaultBalanceByAccountId(const QString& accountId, } } +void LEZWalletAccountModel::setInitializedByAccountId(const QString& accountId, bool isInitialized) +{ + for (int i = 0; i < m_entries.size(); ++i) { + if (m_entries.at(i).accountId == accountId) { + if (m_entries.at(i).isInitialized != isInitialized) { + m_entries[i].isInitialized = isInitialized; + QModelIndex idx = index(i, 0); + emit dataChanged(idx, idx, { IsInitializedRole }); + } + return; + } + } +} + bool LEZWalletAccountModel::isPublicAccount(const QString& accountId, bool defaultValue) const { for (const LEZWalletAccountEntry& e : m_entries) { diff --git a/src/LEZWalletAccountModel.h b/src/LEZWalletAccountModel.h index 7afa6e2..6291172 100644 --- a/src/LEZWalletAccountModel.h +++ b/src/LEZWalletAccountModel.h @@ -19,6 +19,10 @@ struct LEZWalletAccountEntry { 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 + // 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. + bool isInitialized = false; }; // Note: this model is exposed to QML via Qt Remote Objects model replication (see @@ -37,7 +41,8 @@ public: IsPublicRole, SectionKeyRole, KeysJsonRole, - IsFirstInGroupRole + IsFirstInGroupRole, + IsInitializedRole }; Q_ENUM(Role) @@ -50,6 +55,7 @@ public: void replaceFromVariantList(const QVariantList& list); void setBalanceByAccountId(const QString& accountId, const QString& balance); void setVaultBalanceByAccountId(const QString& accountId, const QString& vaultBalance); + void setInitializedByAccountId(const QString& accountId, bool isInitialized); int count() const { return m_entries.size(); } // Authoritative isPublic lookup by account ID — used to validate/derive the flag diff --git a/src/LEZWalletBackend.cpp b/src/LEZWalletBackend.cpp index 1aff8e9..618522f 100644 --- a/src/LEZWalletBackend.cpp +++ b/src/LEZWalletBackend.cpp @@ -1,5 +1,6 @@ #include "LEZWalletBackend.h" +#include #include #include #include @@ -57,6 +58,21 @@ namespace { return QUrl::fromUserInput(p).toLocalFile(); return p; } + + // 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 + // authenticated-transfer program (via an explicit init or as a side effect of + // receiving a transfer to a still-unclaimed account), so "non-zero owner" is + // enough to show as initialized without needing that program's ID here. + bool accountJsonIsInitialized(const QString& accountJson) { + const QJsonDocument doc = QJsonDocument::fromJson(accountJson.toUtf8()); + if (!doc.isObject()) + return false; + const QString programOwner = doc.object().value(QStringLiteral("program_owner")).toString(); + return std::any_of(programOwner.cbegin(), programOwner.cend(), + [](QChar c) { return c != QLatin1Char('0'); }); + } } LEZWalletBackend::LEZWalletBackend(LogosAPI* logosAPI, QObject* parent) @@ -165,8 +181,9 @@ QVariantList LEZWalletBackend::buildEnrichedAccountList() 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 accountId = map.value(QStringLiteral("account_id")).toString(); + const bool isPublic = map.value(QStringLiteral("is_public"), true).toBool(); + if (!isPublic) { const QString keysJson = getPrivateAccountKeys(accountId); const QJsonDocument doc = QJsonDocument::fromJson(keysJson.toUtf8()); if (doc.isObject()) { @@ -174,6 +191,10 @@ QVariantList LEZWalletBackend::buildEnrichedAccountList() map[QStringLiteral("keys_json")] = keysJson; } } + const QString accountJson = isPublic + ? m_logos->lez_core.get_account_public(accountId) + : m_logos->lez_core.get_account_private(accountId); + map[QStringLiteral("is_initialized")] = accountJsonIsInitialized(accountJson); enriched.append(map); } return enriched; @@ -249,6 +270,20 @@ void LEZWalletBackend::updateBalances() m_accountModel->setBalanceByAccountId(addr, bal); else anyFailed = true; + + // Initialization is one-way (program_owner never reverts to zero), so once an + // account is known initialized there's no need to keep re-checking it here. + // Pending accounts get re-checked on every balance refresh so the "Initialize" + // tag catches up once the registration tx lands in a block, without requiring + // another manual Initialize click. + const bool alreadyInitialized = m_accountModel->data(idx, LEZWalletAccountModel::IsInitializedRole).toBool(); + if (!alreadyInitialized) { + const QString accountJson = isPub + ? m_logos->lez_core.get_account_public(addr) + : m_logos->lez_core.get_account_private(addr); + if (accountJsonIsInitialized(accountJson)) + m_accountModel->setInitializedByAccountId(addr, true); + } } if (anyFailed) QTimer::singleShot(3000, this, &LEZWalletBackend::updateBalances); @@ -305,6 +340,20 @@ QString LEZWalletBackend::getPrivateAccountKeys(QString accountIdHex) return m_logos->lez_core.get_private_account_keys(accountIdHex); } +QString LEZWalletBackend::initializeAccount(QString accountIdHex) +{ + // Public accounts only: public account initialization requires authorization, + // so it needs a manual init signed by the owner. Private accounts don't require + // authorization to initialize, so they never go through here. Registration is a + // plain public tx (like transferPublic, no proof needed), so the generated + // accessor's default timeout is fine. + // sendTransaction only waits for mempool acceptance, not block inclusion, so the + // account is never actually initialized yet by the time this returns — no point + // triggering a full (blinking) account-list rebuild here. updateBalances() picks + // up the is_initialized flip later, without a full reset, once the tx confirms. + return m_logos->lez_core.register_public_account(accountIdHex); +} + bool LEZWalletBackend::syncToBlock(quint64 blockId) { int err = m_logos->lez_core.sync_to_block(blockId); diff --git a/src/LEZWalletBackend.h b/src/LEZWalletBackend.h index 469ee63..1131ccf 100644 --- a/src/LEZWalletBackend.h +++ b/src/LEZWalletBackend.h @@ -41,6 +41,7 @@ public slots: void refreshBalances() override; QString getPublicAccountKey(QString accountIdHex) override; QString getPrivateAccountKeys(QString accountIdHex) override; + QString initializeAccount(QString accountIdHex) override; bool syncToBlock(quint64 blockId) override; QString transferPublic(QString fromHex, QString toHex, QString amountStr) override; QString transferPrivate(QString fromHex, QString toHex, QString amountStr) override; diff --git a/src/LEZWalletBackend.rep b/src/LEZWalletBackend.rep index ab56de3..7cbd81d 100644 --- a/src/LEZWalletBackend.rep +++ b/src/LEZWalletBackend.rep @@ -14,6 +14,7 @@ class LEZWalletBackend SLOT(void refreshBalances()) SLOT(QString getPublicAccountKey(QString accountIdHex)) SLOT(QString getPrivateAccountKeys(QString accountIdHex)) + SLOT(QString initializeAccount(QString accountIdHex)) SLOT(bool syncToBlock(quint64 blockId)) SLOT(QString transferPublic(QString fromHex, QString toHex, QString amountStr)) diff --git a/src/qml/ExecutionZoneWalletView.qml b/src/qml/ExecutionZoneWalletView.qml index e6d4fdf..bb077df 100644 --- a/src/qml/ExecutionZoneWalletView.qml +++ b/src/qml/ExecutionZoneWalletView.qml @@ -15,6 +15,9 @@ Rectangle { readonly property var privateAccountModel: logos.model("lez_wallet_ui", "privateAccountModel") readonly property var claimableAccountModel: logos.model("lez_wallet_ui", "claimableAccountModel") property bool ready: false + // Maps accountId -> true while that account's initializeAccount() call is in flight, + // so AccountDelegate can show the click was registered instead of appearing inert. + property var pendingInitializations: ({}) Connections { target: logos @@ -145,13 +148,18 @@ Rectangle { claimableAccountModel: root.claimableAccountModel lastSyncedBlock: backend ? backend.lastSyncedBlock : 0 currentBlockHeight: backend ? backend.currentBlockHeight : 0 + pendingInitializations: root.pendingInitializations - onCreatePublicAccountRequested: { + onCreatePublicAccountRequested: (initializeOnCreate) => { if (!backend) { console.warn("backend is null"); return } - // Result not consumed here — accountModel updates via NOTIFY when - // the backend's refreshAccounts() runs after creation. + // accountModel updates via NOTIFY when the backend's refreshAccounts() + // runs after creation; the id is only needed to chase it with an + // initializeAccount() call when the user asked for that. logos.watch(backend.createAccountPublic(), - function(_id) { /* ignored */ }, + function(id) { + if (initializeOnCreate && id) + dashboardView.initializeAccount(id) + }, function(error) { console.warn("createAccountPublic failed:", error) }) } onCreatePrivateAccountRequested: { @@ -277,6 +285,40 @@ Rectangle { clipHelper.selectAll() clipHelper.copy() } + onInitializeAccountRequested: (accountId) => dashboardView.initializeAccount(accountId) + + // Shared by the manual Initialize button (onInitializeAccountRequested) + // and initialize-on-create (onCreatePublicAccountRequested above). Public + // accounts only: initialization requires authorization, so it's always a + // manual init signed by the owner. Private accounts don't need this. + function initializeAccount(accountId) { + if (!backend) return + // Reassign (not mutate) so the pendingInitializations binding + // propagated down to each AccountDelegate re-evaluates. + var pending = Object.assign({}, root.pendingInitializations) + pending[accountId] = true + root.pendingInitializations = pending + function clearPending() { + var updated = Object.assign({}, root.pendingInitializations) + delete updated[accountId] + root.pendingInitializations = updated + } + // Same {success, tx_hash, error} shape as the transfer/vaultClaim + // slots below, so it gets the same result-panel feedback. The + // accountModel's tag updates via NOTIFY once the backend + // refreshes accounts after a successful initialization. + logos.watch(backend.initializeAccount(accountId), + function(raw) { + clearPending() + ffiErrors.applyTransferResult(dashboardView, raw) + }, + function(error) { + clearPending() + dashboardView.transferResult = qsTr("Error: %1").arg(error) + dashboardView.transferResultIsError = true + dashboardView.transferTxHash = "" + }) + } } } } diff --git a/src/qml/controls/AccountDelegate.qml b/src/qml/controls/AccountDelegate.qml index 77b0e03..fbf7bef 100644 --- a/src/qml/controls/AccountDelegate.qml +++ b/src/qml/controls/AccountDelegate.qml @@ -15,6 +15,17 @@ ItemDelegate { // logos.module() bridge in the parent view. signal copyRequested(string text) + // Emitted when the user clicks "Initialize" on an uninitialized account. The + // parent wires this to backend.initializeAccount(...) for the same reason as + // copyRequested above. Public-only: public account initialization requires + // authorization, so it requires a manual init signed by the owner. Private + // accounts don't need authorization to initialize, so they never need this button. + signal initializeRequested(string accountId) + + // Set by the parent while this account's initializeAccount() call is in flight, + // so the button can show it took the click instead of appearing to do nothing. + property bool initializing: false + leftPadding: Theme.spacing.medium rightPadding: Theme.spacing.medium topPadding: Theme.spacing.medium @@ -54,6 +65,22 @@ ItemDelegate { } } + Rectangle { + Layout.preferredWidth: initLabel.implicitWidth + Theme.spacing.small * 2 + Layout.preferredHeight: initLabel.implicitHeight + 4 + radius: 4 + color: Theme.colors.getColor( + model.isInitialized ? Theme.palette.success : Theme.palette.warning, 0.18) + + LogosText { + id: initLabel + anchors.centerIn: parent + text: model.isInitialized ? qsTr("Initialized") : qsTr("Uninitialized") + font.pixelSize: Theme.typography.secondaryText + color: model.isInitialized ? Theme.palette.success : Theme.palette.warning + } + } + Item { Layout.fillWidth: true } LogosText { @@ -82,5 +109,14 @@ ItemDelegate { icon.color: Theme.palette.textMuted } } + + FeedbackButton { + Layout.fillWidth: true + Layout.preferredHeight: 32 + visible: (model.isPublic ?? true) && !model.isInitialized + enabled: !root.initializing + text: root.initializing ? qsTr("Initializing…") : qsTr("Initialize") + onClicked: root.initializeRequested(model.accountId ?? "") + } } } diff --git a/src/qml/controls/FeedbackButton.qml b/src/qml/controls/FeedbackButton.qml new file mode 100644 index 0000000..5c830fc --- /dev/null +++ b/src/qml/controls/FeedbackButton.qml @@ -0,0 +1,18 @@ +import QtQuick + +import Logos.Theme +import Logos.Controls + +// Drop-in replacement for LogosButton that darkens while pressed. LogosButton's +// own background only reacts to isActive (hover OR press, same color for both), +// so a click on an already-hovered button showed no visible change at all. +LogosButton { + id: root + + Rectangle { + anchors.fill: parent + radius: root.radius + color: Theme.palette.text + opacity: root.mouseAreaItem.pressed ? 0.15 : 0 + } +} diff --git a/src/qml/popups/CreateAccountDialog.qml b/src/qml/popups/CreateAccountDialog.qml index 365a62d..7dd6372 100644 --- a/src/qml/popups/CreateAccountDialog.qml +++ b/src/qml/popups/CreateAccountDialog.qml @@ -5,10 +5,12 @@ import QtQuick.Layouts import Logos.Theme import Logos.Controls +import "../controls" + Popup { id: root - signal createPublicRequested() + signal createPublicRequested(bool initializeOnCreate) signal createPrivateRequested() modal: true @@ -72,20 +74,27 @@ Popup { Layout.fillWidth: true } + LogosCheckbox { + id: initializeOnCreateCheck + checked: true + text: qsTr("Initialize on creation") + visible: tabBar.currentIndex === 0 + } + RowLayout { Layout.topMargin: Theme.spacing.medium spacing: Theme.spacing.medium Layout.fillWidth: true Item { Layout.fillWidth: true } - LogosButton { + FeedbackButton { text: qsTr("Cancel") onClicked: root.close() } - LogosButton { + FeedbackButton { text: qsTr("Create") onClicked: { if (tabBar.currentIndex === 0) - root.createPublicRequested() + root.createPublicRequested(initializeOnCreateCheck.checked) else root.createPrivateRequested() root.close() diff --git a/src/qml/views/AccountsPanel.qml b/src/qml/views/AccountsPanel.qml index d52d33d..ab686a6 100644 --- a/src/qml/views/AccountsPanel.qml +++ b/src/qml/views/AccountsPanel.qml @@ -15,19 +15,22 @@ Rectangle { property var accountModel: null property int lastSyncedBlock: 0 property int currentBlockHeight: 0 + // Maps accountId -> true while that account's initializeAccount() call is in flight. + property var pendingInitializations: ({}) // --- Public API: signals out --- - signal createPublicAccountRequested() + signal createPublicAccountRequested(bool initializeOnCreate) signal createPrivateAccountRequested() signal fetchBalancesRequested() signal copyRequested(string text) + signal initializeAccountRequested(string accountId) radius: Theme.spacing.radiusXlarge color: Theme.palette.backgroundSecondary CreateAccountDialog { id: createAccountDialog - onCreatePublicRequested: root.createPublicAccountRequested() + onCreatePublicRequested: (initializeOnCreate) => root.createPublicAccountRequested(initializeOnCreate) onCreatePrivateRequested: root.createPrivateAccountRequested() } @@ -51,7 +54,7 @@ Rectangle { Item { Layout.fillWidth: true } - LogosButton { + FeedbackButton { Layout.preferredHeight: 40 Layout.preferredWidth: 80 text: qsTr("+ Create") @@ -172,13 +175,15 @@ Rectangle { AccountDelegate { Layout.fillWidth: true + initializing: root.pendingInitializations[model.accountId] === true onCopyRequested: (text) => root.copyRequested(text) + onInitializeRequested: (accountId) => root.initializeAccountRequested(accountId) } } } // Footer: Fetch / Refresh Balances - LogosButton { + FeedbackButton { Layout.fillWidth: true text: qsTr("Refresh Balances") onClicked: root.fetchBalancesRequested() diff --git a/src/qml/views/ClaimDepositPanel.qml b/src/qml/views/ClaimDepositPanel.qml index 5f13d12..2f22c8a 100644 --- a/src/qml/views/ClaimDepositPanel.qml +++ b/src/qml/views/ClaimDepositPanel.qml @@ -5,6 +5,7 @@ import QtQuick.Layouts import Logos.Theme import Logos.Controls +import "../controls" import "../Base58.js" as Base58 Item { @@ -79,7 +80,7 @@ Item { } } - LogosButton { + FeedbackButton { text: qsTr("Claim") enabled: !root.claimPending onClicked: root.vaultClaimRequested(model.accountId, model.isPublic, model.vaultBalance) @@ -88,7 +89,7 @@ Item { } } - LogosButton { + FeedbackButton { Layout.fillWidth: true text: qsTr("Refresh") enabled: !root.claimPending diff --git a/src/qml/views/DashboardView.qml b/src/qml/views/DashboardView.qml index 0076765..ef59cc0 100644 --- a/src/qml/views/DashboardView.qml +++ b/src/qml/views/DashboardView.qml @@ -19,9 +19,10 @@ Rectangle { property bool transferPending: false property int lastSyncedBlock: 0 property int currentBlockHeight: 0 + property var pendingInitializations: ({}) // --- Public API: output signals (parent connects and calls backend) --- - signal createPublicAccountRequested() + signal createPublicAccountRequested(bool initializeOnCreate) signal createPrivateAccountRequested() signal fetchBalancesRequested() signal transferPublicRequested(string fromAccountId, string toAddress, string amount) @@ -34,6 +35,7 @@ Rectangle { signal vaultClaimRequested(string fromAccountId, bool isPublic, string amount) signal refreshClaimableDepositsRequested() signal copyRequested(string copyText) + signal initializeAccountRequested(string accountId) color: Theme.palette.background @@ -50,11 +52,13 @@ Rectangle { accountModel: root.accountModel lastSyncedBlock: root.lastSyncedBlock currentBlockHeight: root.currentBlockHeight + pendingInitializations: root.pendingInitializations - onCreatePublicAccountRequested: root.createPublicAccountRequested() + onCreatePublicAccountRequested: (initializeOnCreate) => root.createPublicAccountRequested(initializeOnCreate) onCreatePrivateAccountRequested: root.createPrivateAccountRequested() onFetchBalancesRequested: root.fetchBalancesRequested() onCopyRequested: (text) => root.copyRequested(text) + onInitializeAccountRequested: (accountId) => root.initializeAccountRequested(accountId) } TransferPanel { diff --git a/src/qml/views/OnboardingView.qml b/src/qml/views/OnboardingView.qml index 71cf5fd..7908729 100644 --- a/src/qml/views/OnboardingView.qml +++ b/src/qml/views/OnboardingView.qml @@ -6,6 +6,8 @@ import QtQuick.Dialogs import Logos.Theme import Logos.Controls +import "../controls" + Control { id: root @@ -66,7 +68,7 @@ Control { placeholderText: qsTr("Add store path") text: root.storePath } - LogosButton { + FeedbackButton { text: qsTr("Browse") onClicked: storageFolderDialog.open() } @@ -86,7 +88,7 @@ Control { placeholderText: qsTr("Add path to config") text: root.configPath } - LogosButton { + FeedbackButton { Layout.preferredHeight: configPathField.height text: qsTr("Browse") onClicked: configFileDialog.open() @@ -113,12 +115,12 @@ Control { RowLayout { Layout.fillWidth: true spacing: Theme.spacing.small - LogosButton { + FeedbackButton { text: qsTr("Testnet") opacity: sequencerUrlField.text === root.testnetUrl ? 1.0 : 0.4 onClicked: sequencerUrlField.text = root.testnetUrl } - LogosButton { + FeedbackButton { text: qsTr("Localhost") opacity: sequencerUrlField.text === root.localhostUrl ? 1.0 : 0.4 onClicked: sequencerUrlField.text = root.localhostUrl @@ -155,7 +157,7 @@ Control { text: root.createError } - LogosButton { + FeedbackButton { Layout.alignment: Qt.AlignRight text: qsTr("Create Wallet") font.pixelSize: Theme.typography.secondaryText diff --git a/src/qml/views/TransferTypesPanel.qml b/src/qml/views/TransferTypesPanel.qml index 8544553..8dd0b67 100644 --- a/src/qml/views/TransferTypesPanel.qml +++ b/src/qml/views/TransferTypesPanel.qml @@ -188,7 +188,7 @@ Item { } // Send button - LogosButton { + FeedbackButton { Layout.fillWidth: true text: qsTr("Send") font.pixelSize: Theme.typography.secondaryText diff --git a/src/qml/views/WithdrawPanel.qml b/src/qml/views/WithdrawPanel.qml index 52ff706..c11f389 100644 --- a/src/qml/views/WithdrawPanel.qml +++ b/src/qml/views/WithdrawPanel.qml @@ -99,7 +99,7 @@ Item { } // Withdraw button - LogosButton { + FeedbackButton { Layout.fillWidth: true text: qsTr("Withdraw") font.pixelSize: Theme.typography.secondaryText