Merge da317802c7714873b9136cf488d95d381a91e985 into da1b021db5315eccd1e39835268bbc08633adc12

This commit is contained in:
Sergio Chouhy 2026-07-25 00:57:43 -03:00 committed by GitHub
commit f4ad56c091
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 218 additions and 27 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 IsInitializedRole: return e.isInitialized;
default: return QVariant();
}
}
@ -45,7 +46,8 @@ QHash<int, QByteArray> 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) {

View File

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

View File

@ -1,5 +1,6 @@
#include "LEZWalletBackend.h"
#include <algorithm>
#include <QAbstractItemModel>
#include <QClipboard>
#include <QCoreApplication>
@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -188,7 +188,7 @@ Item {
}
// Send button
LogosButton {
FeedbackButton {
Layout.fillWidth: true
text: qsTr("Send")
font.pixelSize: Theme.typography.secondaryText

View File

@ -99,7 +99,7 @@ Item {
}
// Withdraw button
LogosButton {
FeedbackButton {
Layout.fillWidth: true
text: qsTr("Withdraw")
font.pixelSize: Theme.typography.secondaryText