diff --git a/src/LEZAccountFilterModel.cpp b/src/LEZAccountFilterModel.cpp index c43046f..4562985 100644 --- a/src/LEZAccountFilterModel.cpp +++ b/src/LEZAccountFilterModel.cpp @@ -31,7 +31,7 @@ bool LEZAccountFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex& s int LEZAccountFilterModel::rowForAddress(const QString& address) const { for (int i = 0; i < rowCount(); ++i) { - if (data(index(i, 0), LEZWalletAccountModel::AddressRole).toString() == address) + if (data(index(i, 0), LEZWalletAccountModel::AccountIdRole).toString() == address) return i; } return -1; diff --git a/src/LEZWalletAccountModel.cpp b/src/LEZWalletAccountModel.cpp index 2381024..b9df171 100644 --- a/src/LEZWalletAccountModel.cpp +++ b/src/LEZWalletAccountModel.cpp @@ -21,7 +21,7 @@ QVariant LEZWalletAccountModel::data(const QModelIndex& index, int role) const const LEZWalletAccountEntry& e = m_entries.at(index.row()); switch (role) { case NameRole: return e.name; - case AddressRole: return e.address; + case AccountIdRole: return e.accountId; case BalanceRole: return e.balance; case IsPublicRole: return e.isPublic; default: return QVariant(); @@ -32,7 +32,7 @@ QHash LEZWalletAccountModel::roleNames() const { return { { NameRole, "name" }, - { AddressRole, "address" }, + { AccountIdRole, "accountId" }, { BalanceRole, "balance" }, { IsPublicRole, "isPublic" } }; @@ -43,19 +43,18 @@ void LEZWalletAccountModel::replaceFromJsonArray(const QJsonArray& arr) beginResetModel(); int oldCount = m_entries.size(); m_entries.clear(); - int idx = 0; for (const QJsonValue& v : arr) { LEZWalletAccountEntry e; - e.name = QStringLiteral("Account %1").arg(++idx); e.balance = QString(); if (v.isObject()) { const QJsonObject obj = v.toObject(); - e.address = obj.value(QStringLiteral("account_id")).toString(); + e.accountId = obj.value(QStringLiteral("account_id")).toString(); e.isPublic = obj.value(QStringLiteral("is_public")).toBool(true); } else { - e.address = v.toString(); + e.accountId = v.toString(); e.isPublic = true; } + e.name = QString(); m_entries.append(e); } endResetModel(); @@ -63,10 +62,10 @@ void LEZWalletAccountModel::replaceFromJsonArray(const QJsonArray& arr) emit countChanged(); } -void LEZWalletAccountModel::setBalanceByAddress(const QString& address, const QString& balance) +void LEZWalletAccountModel::setBalanceByAccountId(const QString& accountId, const QString& balance) { for (int i = 0; i < m_entries.size(); ++i) { - if (m_entries.at(i).address == address) { + if (m_entries.at(i).accountId == accountId) { if (m_entries.at(i).balance != balance) { m_entries[i].balance = balance; QModelIndex idx = index(i, 0); diff --git a/src/LEZWalletAccountModel.h b/src/LEZWalletAccountModel.h index 6092c8f..8efb086 100644 --- a/src/LEZWalletAccountModel.h +++ b/src/LEZWalletAccountModel.h @@ -6,7 +6,7 @@ struct LEZWalletAccountEntry { QString name; - QString address; + QString accountId; QString balance; bool isPublic = true; }; @@ -17,7 +17,7 @@ class LEZWalletAccountModel : public QAbstractListModel { public: enum Role { NameRole = Qt::UserRole + 1, - AddressRole, + AccountIdRole, BalanceRole, IsPublicRole }; @@ -30,7 +30,7 @@ public: QHash roleNames() const override; void replaceFromJsonArray(const QJsonArray& arr); - void setBalanceByAddress(const QString& address, const QString& balance); + void setBalanceByAccountId(const QString& accountId, const QString& balance); int count() const { return m_entries.size(); } signals: diff --git a/src/LEZWalletBackend.cpp b/src/LEZWalletBackend.cpp index e8f8f5b..5ed2c17 100644 --- a/src/LEZWalletBackend.cpp +++ b/src/LEZWalletBackend.cpp @@ -4,13 +4,19 @@ #include #include #include +#include +#include +#include #include #include +#include +#include #include #include #include #include "logos_api.h" +#include "logos_api_client.h" #include "logos_sdk.h" namespace { @@ -18,7 +24,12 @@ namespace { const char SETTINGS_APP[] = "ExecutionZoneWalletUI"; const char CONFIG_PATH_KEY[] = "configPath"; const char STORAGE_PATH_KEY[] = "storagePath"; + const char LEZ_MODULE[] = "logos_execution_zone"; const int WALLET_FFI_SUCCESS = 0; + // Proof generation time is unbounded on commodity hardware. + // Timeout(-1) means "wait indefinitely", matching Qt's own convention + // for infinite waits (e.g. QRemoteObjectPendingCall::waitForFinished(-1)). + const Timeout NO_TIMEOUT{-1}; // Convert a decimal amount string to 32-char hex (16 bytes little-endian) // for transfer_public/transfer_private/transfer_private_owned. @@ -36,9 +47,12 @@ namespace { // Normalise file:// URLs and OS paths to a plain local path. QString toLocalPath(const QString& path) { - if (path.startsWith("file://") || path.contains("/")) - return QUrl::fromUserInput(path).toLocalFile(); - return path; + QString p = path; + if (p.startsWith(QLatin1Char('~'))) + p = QDir::homePath() + p.mid(1); + if (p.startsWith("file://") || p.contains("/")) + return QUrl::fromUserInput(p).toLocalFile(); + return p; } } @@ -46,10 +60,13 @@ LEZWalletBackend::LEZWalletBackend(LogosAPI* logosAPI, QObject* parent) : LEZWalletBackendSimpleSource(parent), m_accountModel(new LEZWalletAccountModel(this)), m_filteredAccountModel(new LEZAccountFilterModel(this)), + m_privateAccountModel(new LEZAccountFilterModel(this)), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("lez_wallet_ui", this)), m_logos(new LogosModules(m_logosAPI)) { m_filteredAccountModel->setSourceModel(m_accountModel); + m_privateAccountModel->setFilterByPublic(false); + m_privateAccountModel->setSourceModel(m_accountModel); // Initialise PROP defaults via the generated setters. setIsWalletOpen(false); @@ -111,9 +128,14 @@ void LEZWalletBackend::openIfPathsConfigured() if (err == WALLET_FFI_SUCCESS) { qDebug() << "LEZWalletBackend: wallet opened successfully"; setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); + QJsonArray arr = m_logos->logos_execution_zone.list_accounts(); + m_accountModel->replaceFromJsonArray(arr); + fetchAndUpdateBlockHeights(); + startChunkedSync(); refreshSequencerAddr(); + } else { + qWarning() << "LEZWalletBackend: failed to open wallet, error" << err + << "config:" << configPath() << "storage:" << storagePath(); } } @@ -121,21 +143,66 @@ void LEZWalletBackend::refreshAccounts() { QJsonArray arr = m_logos->logos_execution_zone.list_accounts(); m_accountModel->replaceFromJsonArray(arr); - refreshBalances(); + fetchAndUpdateBlockHeights(); + if (!m_syncing) + startChunkedSync(); } void LEZWalletBackend::refreshBalances() { - refreshBlockHeights(); - syncToBlock(currentBlockHeight()); + fetchAndUpdateBlockHeights(); + if (!m_syncing) + startChunkedSync(); +} + +void LEZWalletBackend::startChunkedSync() +{ + m_syncTarget = static_cast(currentBlockHeight()); + if (m_syncTarget == 0 || static_cast(lastSyncedBlock()) >= m_syncTarget) { + updateBalances(); + return; + } + m_syncing = true; + syncNextChunk(); +} + +void LEZWalletBackend::syncNextChunk() +{ + const quint64 synced = static_cast(lastSyncedBlock()); + if (synced >= m_syncTarget) { + m_syncing = false; + fetchAndUpdateBlockHeights(); + updateBalances(); + return; + } + const quint64 next = qMin(synced + SYNC_CHUNK_SIZE, m_syncTarget); + m_logos->logos_execution_zone.sync_to_block(next); + // Only read lastSyncedBlock between chunks — avoids a sequencer network + // call (get_current_block_height) on every iteration. + const int lastVal = m_logos->logos_execution_zone.get_last_synced_block(); + if (lastSyncedBlock() != lastVal) + setLastSyncedBlock(lastVal); + QTimer::singleShot(0, this, &LEZWalletBackend::syncNextChunk); +} + +void LEZWalletBackend::updateBalances() +{ if (!m_accountModel) return; + bool anyFailed = false; for (int i = 0; i < m_accountModel->count(); ++i) { const QModelIndex idx = m_accountModel->index(i, 0); - const QString addr = m_accountModel->data(idx, LEZWalletAccountModel::AddressRole).toString(); + const QString addr = m_accountModel->data(idx, LEZWalletAccountModel::AccountIdRole).toString(); const bool isPub = m_accountModel->data(idx, LEZWalletAccountModel::IsPublicRole).toBool(); - m_accountModel->setBalanceByAddress(addr, getBalance(addr, isPub)); + const QString bal = getBalance(addr, isPub); + if (!bal.isEmpty()) + m_accountModel->setBalanceByAccountId(addr, bal); + else + anyFailed = true; } - saveWallet(); + if (anyFailed) + QTimer::singleShot(3000, this, &LEZWalletBackend::updateBalances); + else + saveWallet(); } void LEZWalletBackend::fetchAndUpdateBlockHeights() @@ -148,16 +215,6 @@ void LEZWalletBackend::fetchAndUpdateBlockHeights() setCurrentBlockHeight(currentVal); } -void LEZWalletBackend::refreshBlockHeights() -{ - fetchAndUpdateBlockHeights(); - if (currentBlockHeight() > 0 - && lastSyncedBlock() < currentBlockHeight() - && syncToBlock(currentBlockHeight())) - { - fetchAndUpdateBlockHeights(); - } -} void LEZWalletBackend::refreshSequencerAddr() { @@ -216,7 +273,6 @@ QString LEZWalletBackend::transferPrivate(QString fromHex, QString toHex, QStrin if (amountHex.isEmpty()) return QStringLiteral("Error: Invalid amount."); QString keysPayload = toHex.trimmed(); - // If "To" is not JSON (e.g. user pasted account id hex), resolve to keys. if (!keysPayload.startsWith(QLatin1Char('{'))) { qDebug() << "LEZWalletBackend::transferPrivate: resolving keys via get_private_account_keys"; const QString resolved = getPrivateAccountKeys(keysPayload); @@ -224,27 +280,97 @@ QString LEZWalletBackend::transferPrivate(QString fromHex, QString toHex, QStrin keysPayload = resolved; } - return m_logos->logos_execution_zone.transfer_private(fromHex, keysPayload, amountHex); + return m_logosAPI->getClient(LEZ_MODULE)->invokeRemoteMethod( + LEZ_MODULE, "transfer_private", + QVariantList{fromHex.trimmed(), keysPayload, amountHex}, + NO_TIMEOUT).toString(); } QString LEZWalletBackend::transferPrivateOwned(QString fromHex, QString toHex, QString amountStr) { const QString amountHex = amountToLe16Hex(amountStr); if (amountHex.isEmpty()) return QStringLiteral("Error: Invalid amount."); - return m_logos->logos_execution_zone.transfer_private_owned(fromHex, toHex.trimmed(), amountHex); + return m_logosAPI->getClient(LEZ_MODULE)->invokeRemoteMethod( + LEZ_MODULE, "transfer_private_owned", + QVariantList{fromHex.trimmed(), toHex.trimmed(), amountHex}, + NO_TIMEOUT).toString(); } -bool LEZWalletBackend::createNew(QString configPath, QString storagePath, QString password) +QString LEZWalletBackend::transferShielded(QString fromHex, QString toKeysJson, QString amountStr) { - const QString localPath = toLocalPath(configPath); - int err = m_logos->logos_execution_zone.create_new(localPath, storagePath, password); + const QString amountHex = amountToLe16Hex(amountStr); + if (amountHex.isEmpty()) return QStringLiteral("Error: Invalid amount."); + + QString keysPayload = toKeysJson.trimmed(); + if (!keysPayload.startsWith(QLatin1Char('{'))) { + qDebug() << "LEZWalletBackend::transferShielded: resolving keys via get_private_account_keys"; + const QString resolved = getPrivateAccountKeys(keysPayload); + if (!resolved.isEmpty()) + keysPayload = resolved; + } + + return m_logosAPI->getClient(LEZ_MODULE)->invokeRemoteMethod( + LEZ_MODULE, "transfer_shielded", + QVariantList{fromHex.trimmed(), keysPayload, amountHex}, + NO_TIMEOUT).toString(); +} + +QString LEZWalletBackend::transferShieldedOwned(QString fromHex, QString toHex, QString amountStr) +{ + const QString amountHex = amountToLe16Hex(amountStr); + if (amountHex.isEmpty()) return QStringLiteral("Error: Invalid amount."); + return m_logosAPI->getClient(LEZ_MODULE)->invokeRemoteMethod( + LEZ_MODULE, "transfer_shielded_owned", + QVariantList{fromHex.trimmed(), toHex.trimmed(), amountHex}, + NO_TIMEOUT).toString(); +} + +QString LEZWalletBackend::transferDeshielded(QString fromHex, QString toHex, QString amountStr) +{ + const QString amountHex = amountToLe16Hex(amountStr); + if (amountHex.isEmpty()) return QStringLiteral("Error: Invalid amount."); + return m_logosAPI->getClient(LEZ_MODULE)->invokeRemoteMethod( + LEZ_MODULE, "transfer_deshielded", + QVariantList{fromHex.trimmed(), toHex.trimmed(), amountHex}, + NO_TIMEOUT).toString(); +} + +void LEZWalletBackend::applySequencerAddrToConfig(const QString& configPath, const QString& sequencerAddr) +{ + QJsonObject obj; + QFile file(configPath); + if (file.open(QIODevice::ReadOnly)) { + obj = QJsonDocument::fromJson(file.readAll()).object(); + file.close(); + } else { + // Defaults matching WalletConfig::default() in the wallet crate. + obj[QStringLiteral("seq_poll_timeout")] = QStringLiteral("30s"); + obj[QStringLiteral("seq_tx_poll_max_blocks")] = 15; + obj[QStringLiteral("seq_poll_max_retries")] = 10; + obj[QStringLiteral("seq_block_poll_max_amount")] = 100; + } + obj[QStringLiteral("sequencer_addr")] = sequencerAddr; + + QDir().mkpath(QFileInfo(configPath).absolutePath()); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented)); +} + +bool LEZWalletBackend::createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr) +{ + const QString localConfigPath = toLocalPath(configPath); + const QString localStoragePath = toLocalPath(storagePath); + + if (!sequencerAddr.isEmpty()) + applySequencerAddrToConfig(localConfigPath, sequencerAddr); + + int err = m_logos->logos_execution_zone.create_new(localConfigPath, localStoragePath, password); if (err != WALLET_FFI_SUCCESS) return false; - persistConfigPath(localPath); - persistStoragePath(storagePath); + persistConfigPath(localConfigPath); + persistStoragePath(localStoragePath); setIsWalletOpen(true); refreshAccounts(); - refreshBlockHeights(); refreshSequencerAddr(); return true; } diff --git a/src/LEZWalletBackend.h b/src/LEZWalletBackend.h index 0ebcc3e..7706d8d 100644 --- a/src/LEZWalletBackend.h +++ b/src/LEZWalletBackend.h @@ -19,6 +19,7 @@ class LEZWalletBackend : public LEZWalletBackendSimpleSource { Q_OBJECT Q_PROPERTY(LEZWalletAccountModel* accountModel READ accountModel CONSTANT) Q_PROPERTY(LEZAccountFilterModel* filteredAccountModel READ filteredAccountModel CONSTANT) + Q_PROPERTY(LEZAccountFilterModel* privateAccountModel READ privateAccountModel CONSTANT) public: explicit LEZWalletBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); @@ -26,6 +27,7 @@ public: LEZWalletAccountModel* accountModel() const { return m_accountModel; } LEZAccountFilterModel* filteredAccountModel() const { return m_filteredAccountModel; } + LEZAccountFilterModel* privateAccountModel() const { return m_privateAccountModel; } public slots: // Overrides of the pure-virtual slots generated from the .rep. @@ -40,20 +42,34 @@ public slots: QString transferPublic(QString fromHex, QString toHex, QString amountStr) override; QString transferPrivate(QString fromHex, QString toHex, QString amountStr) override; QString transferPrivateOwned(QString fromHex, QString toHex, QString amountStr) override; - bool createNew(QString configPath, QString storagePath, QString password) override; + QString transferShielded(QString fromHex, QString toKeysJson, QString amountStr) override; + QString transferShieldedOwned(QString fromHex, QString toHex, QString amountStr) override; + QString transferDeshielded(QString fromHex, QString toHex, QString amountStr) override; + bool createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr) override; void copyToClipboard(QString text) override; +private slots: + void syncNextChunk(); + private: void persistConfigPath(const QString& path); void persistStoragePath(const QString& path); - void refreshBlockHeights(); + void applySequencerAddrToConfig(const QString& configPath, const QString& sequencerAddr); + void fetchAndUpdateBlockHeights(); + void startChunkedSync(); + + void updateBalances(); void refreshSequencerAddr(); void saveWallet(); - void fetchAndUpdateBlockHeights(); void openIfPathsConfigured(); + bool m_syncing = false; + quint64 m_syncTarget = 0; + static constexpr quint64 SYNC_CHUNK_SIZE = 100; + LEZWalletAccountModel* m_accountModel; LEZAccountFilterModel* m_filteredAccountModel; + LEZAccountFilterModel* m_privateAccountModel; LogosAPI* m_logosAPI; LogosModules* m_logos; diff --git a/src/LEZWalletBackend.rep b/src/LEZWalletBackend.rep index aec5790..376f3e4 100644 --- a/src/LEZWalletBackend.rep +++ b/src/LEZWalletBackend.rep @@ -19,8 +19,11 @@ class LEZWalletBackend SLOT(QString transferPublic(QString fromHex, QString toHex, QString amountStr)) SLOT(QString transferPrivate(QString fromHex, QString toHex, QString amountStr)) SLOT(QString transferPrivateOwned(QString fromHex, QString toHex, QString amountStr)) + SLOT(QString transferShielded(QString fromHex, QString toKeysJson, QString amountStr)) + SLOT(QString transferShieldedOwned(QString fromHex, QString toHex, QString amountStr)) + SLOT(QString transferDeshielded(QString fromHex, QString toHex, QString amountStr)) - SLOT(bool createNew(QString configPath, QString storagePath, QString password)) + SLOT(bool createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr)) SLOT(void copyToClipboard(QString text)) } diff --git a/src/qml/Base58.js b/src/qml/Base58.js new file mode 100644 index 0000000..9477e1e --- /dev/null +++ b/src/qml/Base58.js @@ -0,0 +1,71 @@ +.pragma library + +const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// Encode a hex string to base58. +function encode(hexStr) { + if (!hexStr || hexStr.length === 0) return "" + const clean = hexStr.toLowerCase().replace(/^0x/, "") + if (clean.length === 0 || clean.length % 2 !== 0) return hexStr + + const bytes = [] + for (let i = 0; i < clean.length; i += 2) + bytes.push(parseInt(clean.substr(i, 2), 16)) + + let leadingZeros = 0 + while (leadingZeros < bytes.length && bytes[leadingZeros] === 0) + leadingZeros++ + + // digits[i] holds base-58 digits, least-significant first. + // For each input byte, multiply existing digits by 256 and add the byte. + const digits = [] + for (let i = 0; i < bytes.length; i++) { + let carry = bytes[i] + for (let j = 0; j < digits.length; j++) { + carry += digits[j] * 256 + digits[j] = carry % 58 + carry = Math.floor(carry / 58) + } + while (carry > 0) { + digits.push(carry % 58) + carry = Math.floor(carry / 58) + } + } + + let result = "1".repeat(leadingZeros) + for (let i = digits.length - 1; i >= 0; i--) + result += ALPHABET[digits[i]] + return result +} + +// Decode a base58 string to hex. +function decode(b58Str) { + if (!b58Str || b58Str.length === 0) return "" + + let leadingZeros = 0 + while (leadingZeros < b58Str.length && b58Str[leadingZeros] === "1") + leadingZeros++ + + // digits[i] holds base-256 (byte) digits, least-significant first. + // For each input char, multiply existing digits by 58 and add the char value. + const digits = [] + for (let i = 0; i < b58Str.length; i++) { + const idx = ALPHABET.indexOf(b58Str[i]) + if (idx < 0) return "" + let carry = idx + for (let j = 0; j < digits.length; j++) { + carry += digits[j] * 58 + digits[j] = carry % 256 + carry = Math.floor(carry / 256) + } + while (carry > 0) { + digits.push(carry % 256) + carry = Math.floor(carry / 256) + } + } + + let hex = "00".repeat(leadingZeros) + for (let i = digits.length - 1; i >= 0; i--) + hex += (digits[i] < 16 ? "0" : "") + digits[i].toString(16) + return hex +} diff --git a/src/qml/ExecutionZoneWalletView.qml b/src/qml/ExecutionZoneWalletView.qml index 9814e46..1b46a66 100644 --- a/src/qml/ExecutionZoneWalletView.qml +++ b/src/qml/ExecutionZoneWalletView.qml @@ -11,6 +11,8 @@ Rectangle { readonly property var backend: logos.module("lez_wallet_ui") readonly property var accountModel: logos.model("lez_wallet_ui", "accountModel") + readonly property var publicAccountModel: logos.model("lez_wallet_ui", "filteredAccountModel") + readonly property var privateAccountModel: logos.model("lez_wallet_ui", "privateAccountModel") property bool ready: false Connections { @@ -59,7 +61,6 @@ Rectangle { } // Parse a transfer result JSON string and write to dashboardView. - // Used by all three transfer handlers below. function applyTransferResult(dashboardView, raw) { var msg = raw || "" var isError = false @@ -76,6 +77,7 @@ Rectangle { } dashboardView.transferResult = msg dashboardView.transferResultIsError = isError + dashboardView.transferTxHash = (obj && obj.tx_hash) ? obj.tx_hash : "" } } @@ -114,9 +116,9 @@ Rectangle { OnboardingView { storePath: backend ? backend.storagePath : "" configPath: backend ? backend.configPath : "" - onCreateWallet: function(configPath, storagePath, password) { + onCreateWallet: function(configPath, storagePath, password, sequencerUrl) { if (!backend) return - logos.watch(backend.createNew(configPath, storagePath, password), + logos.watch(backend.createNew(configPath, storagePath, password, sequencerUrl), function(ok) { if (!ok) createError = qsTr("Failed to create wallet. Check paths and try again.") @@ -134,6 +136,10 @@ Rectangle { DashboardView { id: dashboardView accountModel: root.accountModel + publicAccountModel: root.publicAccountModel + privateAccountModel: root.privateAccountModel + lastSyncedBlock: backend ? backend.lastSyncedBlock : 0 + currentBlockHeight: backend ? backend.currentBlockHeight : 0 onCreatePublicAccountRequested: { if (!backend) { console.warn("backend is null"); return } @@ -160,24 +166,67 @@ Rectangle { function(error) { dashboardView.transferResult = qsTr("Error: %1").arg(error) dashboardView.transferResultIsError = true + dashboardView.transferTxHash = "" }) } onTransferPrivateRequested: (fromId, toKeysJsonOrAddress, amount) => { if (!backend) return + dashboardView.transferPending = true logos.watch(backend.transferPrivate(fromId, toKeysJsonOrAddress, amount), - function(raw) { ffiErrors.applyTransferResult(dashboardView, raw) }, + function(raw) { dashboardView.transferPending = false; ffiErrors.applyTransferResult(dashboardView, raw) }, function(error) { + dashboardView.transferPending = false dashboardView.transferResult = qsTr("Error: %1").arg(error) dashboardView.transferResultIsError = true + dashboardView.transferTxHash = "" }) } onTransferPrivateOwnedRequested: (fromId, toAccountId, amount) => { if (!backend) return + dashboardView.transferPending = true logos.watch(backend.transferPrivateOwned(fromId, toAccountId, amount), - function(raw) { ffiErrors.applyTransferResult(dashboardView, raw) }, + function(raw) { dashboardView.transferPending = false; ffiErrors.applyTransferResult(dashboardView, raw) }, function(error) { + dashboardView.transferPending = false dashboardView.transferResult = qsTr("Error: %1").arg(error) dashboardView.transferResultIsError = true + dashboardView.transferTxHash = "" + }) + } + onTransferShieldedRequested: (fromId, toKeysJsonOrAddress, amount) => { + if (!backend) return + dashboardView.transferPending = true + logos.watch(backend.transferShielded(fromId, toKeysJsonOrAddress, amount), + function(raw) { dashboardView.transferPending = false; ffiErrors.applyTransferResult(dashboardView, raw) }, + function(error) { + dashboardView.transferPending = false + dashboardView.transferResult = qsTr("Error: %1").arg(error) + dashboardView.transferResultIsError = true + dashboardView.transferTxHash = "" + }) + } + onTransferShieldedOwnedRequested: (fromId, toAccountId, amount) => { + if (!backend) return + dashboardView.transferPending = true + logos.watch(backend.transferShieldedOwned(fromId, toAccountId, amount), + function(raw) { dashboardView.transferPending = false; ffiErrors.applyTransferResult(dashboardView, raw) }, + function(error) { + dashboardView.transferPending = false + dashboardView.transferResult = qsTr("Error: %1").arg(error) + dashboardView.transferResultIsError = true + dashboardView.transferTxHash = "" + }) + } + onTransferDeshieldedRequested: (fromId, toAccountId, amount) => { + if (!backend) return + dashboardView.transferPending = true + logos.watch(backend.transferDeshielded(fromId, toAccountId, amount), + function(raw) { dashboardView.transferPending = false; ffiErrors.applyTransferResult(dashboardView, raw) }, + function(error) { + dashboardView.transferPending = false + dashboardView.transferResult = qsTr("Error: %1").arg(error) + dashboardView.transferResultIsError = true + dashboardView.transferTxHash = "" }) } onCopyRequested: (copyText) => { @@ -185,6 +234,16 @@ 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 0dafffe..8d42c27 100644 --- a/src/qml/controls/AccountComboBox.qml +++ b/src/qml/controls/AccountComboBox.qml @@ -4,6 +4,7 @@ import QtQuick.Layouts import Logos.Theme import Logos.Controls +import "../Base58.js" as Base58 ComboBox { id: root @@ -11,12 +12,13 @@ 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 implicitHeight: 40 textRole: "name" - valueRole: "address" + valueRole: "accountId" background: Rectangle { radius: Theme.spacing.radiusSmall @@ -45,7 +47,7 @@ ComboBox { selectByMouse: true font.pixelSize: Theme.typography.secondaryText color: Theme.palette.text - text: root.displayText + text: root.currentValue ? ("Account " + Base58.encode(root.currentValue).substring(0, 4)) : root.displayText verticalAlignment: Text.AlignVCenter clip: true } @@ -60,6 +62,7 @@ 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 676d8b6..9c4be1f 100644 --- a/src/qml/controls/AccountDelegate.qml +++ b/src/qml/controls/AccountDelegate.qml @@ -4,6 +4,7 @@ import QtQuick.Layouts import Logos.Theme import Logos.Controls +import "../Base58.js" as Base58 ItemDelegate { id: root @@ -13,6 +14,7 @@ ItemDelegate { // the global QML scope for `backend` since it now lives behind the // logos.module() bridge in the parent view. signal copyRequested(string text) + signal copyPublicKeysRequested(string accountIdHex) leftPadding: Theme.spacing.medium rightPadding: Theme.spacing.medium @@ -33,7 +35,7 @@ ItemDelegate { spacing: Theme.spacing.small LogosText { - text: model.name ?? "" + text: model.name || ("Account " + Base58.encode(model.accountId ?? "").slice(0, 4)) font.pixelSize: Theme.typography.secondaryText font.bold: true } @@ -68,7 +70,7 @@ ItemDelegate { id: addressLabel Layout.fillWidth: true verticalAlignment: Text.AlignVCenter - text: model.address ?? "" + text: Base58.encode(model.accountId ?? "") font.pixelSize: Theme.typography.secondaryText color: Theme.palette.textMuted elide: Text.ElideMiddle @@ -76,7 +78,9 @@ ItemDelegate { LogosCopyButton { Layout.preferredHeight: 40 Layout.preferredWidth: 40 - onCopyText: root.copyRequested(model.address) + onCopyText: model.isPublic + ? root.copyRequested(Base58.encode(model.accountId ?? "")) + : root.copyPublicKeysRequested(model.accountId ?? "") visible: addressLabel.text icon.color: Theme.palette.textMuted } diff --git a/src/qml/popups/CreateAccountDialog.qml b/src/qml/popups/CreateAccountDialog.qml index d07c3f2..365a62d 100644 --- a/src/qml/popups/CreateAccountDialog.qml +++ b/src/qml/popups/CreateAccountDialog.qml @@ -64,7 +64,7 @@ Popup { LogosText { text: tabBar.currentIndex === 0 - ? qsTr("Address visible. Balance on-chain.") + ? qsTr("Account ID visible. Balance on-chain.") : qsTr("Private balance and activity.") font.pixelSize: Theme.typography.secondaryText color: Theme.palette.textSecondary diff --git a/src/qml/views/AccountsPanel.qml b/src/qml/views/AccountsPanel.qml index f6ff14a..aefce03 100644 --- a/src/qml/views/AccountsPanel.qml +++ b/src/qml/views/AccountsPanel.qml @@ -13,12 +13,15 @@ Rectangle { // --- Public API: data in --- property var accountModel: null + property int lastSyncedBlock: 0 + property int currentBlockHeight: 0 // --- Public API: signals out --- signal createPublicAccountRequested() signal createPrivateAccountRequested() signal fetchBalancesRequested() signal copyRequested(string text) + signal copyPublicKeysRequested(string accountIdHex) radius: Theme.spacing.radiusXlarge color: Theme.palette.backgroundSecondary @@ -57,6 +60,50 @@ Rectangle { } } + // Sync progress + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacing.xsmall + visible: root.currentBlockHeight > 0 && root.lastSyncedBlock < root.currentBlockHeight + + RowLayout { + Layout.fillWidth: true + LogosText { + text: qsTr("Syncing") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + Item { Layout.fillWidth: true } + LogosText { + text: root.lastSyncedBlock + " / " + root.currentBlockHeight + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + } + ProgressBar { + Layout.fillWidth: true + from: 0 + to: root.currentBlockHeight + value: root.lastSyncedBlock + + contentItem: Item { + Rectangle { + width: parent.width * (root.currentBlockHeight > 0 + ? root.lastSyncedBlock / root.currentBlockHeight : 0) + height: parent.height + radius: height / 2 + color: Theme.palette.overlayOrange + } + } + + background: Rectangle { + implicitHeight: 6 + radius: height / 2 + color: Theme.palette.backgroundElevated + } + } + } + // Empty state (when no real model and we don't show showcase) LogosText { Layout.fillWidth: true @@ -83,6 +130,7 @@ Rectangle { delegate: AccountDelegate { width: listView.width onCopyRequested: (text) => root.copyRequested(text) + onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id) } } diff --git a/src/qml/views/DashboardView.qml b/src/qml/views/DashboardView.qml index efd4f3d..d412515 100644 --- a/src/qml/views/DashboardView.qml +++ b/src/qml/views/DashboardView.qml @@ -10,8 +10,14 @@ Rectangle { // --- Public API: input properties (set by parent / MainView) --- property var accountModel: null + property var publicAccountModel: null + property var privateAccountModel: null property string transferResult: "" + property string transferTxHash: "" property bool transferResultIsError: false + property bool transferPending: false + property int lastSyncedBlock: 0 + property int currentBlockHeight: 0 // --- Public API: output signals (parent connects and calls backend) --- signal createPublicAccountRequested() @@ -20,7 +26,11 @@ Rectangle { signal transferPublicRequested(string fromAccountId, string toAddress, string amount) signal transferPrivateRequested(string fromAccountId, string toKeysJsonOrAddress, string amount) signal transferPrivateOwnedRequested(string fromAccountId, string toAccountId, string amount) + signal transferShieldedRequested(string fromAccountId, string toKeysJsonOrAddress, string amount) + 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 @@ -35,24 +45,33 @@ Rectangle { Layout.fillHeight: true accountModel: root.accountModel + lastSyncedBlock: root.lastSyncedBlock + currentBlockHeight: root.currentBlockHeight onCreatePublicAccountRequested: root.createPublicAccountRequested() onCreatePrivateAccountRequested: root.createPrivateAccountRequested() onFetchBalancesRequested: root.fetchBalancesRequested() onCopyRequested: (text) => root.copyRequested(text) + onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id) } TransferPanel { id: transferPanel Layout.fillWidth: true Layout.fillHeight: true - fromAccountModel: root.accountModel + publicAccountModel: root.publicAccountModel + privateAccountModel: root.privateAccountModel transferResult: root.transferResult + transferTxHash: root.transferTxHash transferResultIsError: root.transferResultIsError + transferPending: root.transferPending onTransferPublicRequested: (fromId, toAddress, amount) => root.transferPublicRequested(fromId, toAddress, amount) onTransferPrivateRequested: (fromId, toKeysJsonOrAddress, amount) => root.transferPrivateRequested(fromId, toKeysJsonOrAddress, amount) onTransferPrivateOwnedRequested: (fromId, toAccountId, amount) => root.transferPrivateOwnedRequested(fromId, toAccountId, amount) + onTransferShieldedRequested: (fromId, toKeysJsonOrAddress, amount) => root.transferShieldedRequested(fromId, toKeysJsonOrAddress, amount) + onTransferShieldedOwnedRequested: (fromId, toAccountId, amount) => root.transferShieldedOwnedRequested(fromId, toAccountId, amount) + onTransferDeshieldedRequested: (fromId, toAccountId, amount) => root.transferDeshieldedRequested(fromId, toAccountId, amount) onCopyRequested: (copyText) => root.copyRequested(copyText) } } diff --git a/src/qml/views/OnboardingView.qml b/src/qml/views/OnboardingView.qml index 30bb903..71cf5fd 100644 --- a/src/qml/views/OnboardingView.qml +++ b/src/qml/views/OnboardingView.qml @@ -13,7 +13,10 @@ Control { property string storePath: "" property string createError: "" - signal createWallet(string configPath, string storagePath, string password) + signal createWallet(string configPath, string storagePath, string password, string sequencerUrl) + + readonly property string testnetUrl: "https://testnet.lez.logos.co" + readonly property string localhostUrl: "http://127.0.0.1:3040" QtObject { @@ -90,6 +93,38 @@ Control { } } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Theme.spacing.medium + spacing: Theme.spacing.small + LogosText { + text: qsTr("Network") + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightMedium + color: Theme.palette.text + } + LogosTextField { + id: sequencerUrlField + Layout.fillWidth: true + placeholderText: qsTr("Sequencer URL") + text: root.testnetUrl + } + } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + LogosButton { + text: qsTr("Testnet") + opacity: sequencerUrlField.text === root.testnetUrl ? 1.0 : 0.4 + onClicked: sequencerUrlField.text = root.testnetUrl + } + LogosButton { + text: qsTr("Localhost") + opacity: sequencerUrlField.text === root.localhostUrl ? 1.0 : 0.4 + onClicked: sequencerUrlField.text = root.localhostUrl + } + } + LogosText { text: qsTr("Security") font.pixelSize: Theme.typography.secondaryText @@ -131,7 +166,7 @@ Control { root.createError = qsTr("Passwords do not match.") } else { root.createError = "" - root.createWallet(configPathField.text, storagePathField.text, passwordField.text) + root.createWallet(configPathField.text, storagePathField.text, passwordField.text, sequencerUrlField.text) } } } diff --git a/src/qml/views/TransferPanel.qml b/src/qml/views/TransferPanel.qml index 9240d3f..73d2f98 100644 --- a/src/qml/views/TransferPanel.qml +++ b/src/qml/views/TransferPanel.qml @@ -6,31 +6,45 @@ import Logos.Theme import Logos.Controls import "../controls" +import "../Base58.js" as Base58 Rectangle { id: root // --- Public API: data in --- - property var fromAccountModel: null + property var publicAccountModel: null + property var privateAccountModel: null property string transferResult: "" + property string transferTxHash: "" property bool transferResultIsError: false + property bool transferPending: false - // --- Public API: signals out (match backend: transfer_public, transfer_private, transfer_private_owned) --- + // --- Public API: signals out (match backend: transfer_public, transfer_private, transfer_private_owned, transfer_shielded, transfer_shielded_owned) --- signal transferPublicRequested(string fromAccountId, string toAddress, string amount) signal transferPrivateRequested(string fromAccountId, string toKeysJsonOrAddress, string amount) signal transferPrivateOwnedRequested(string fromAccountId, string toAccountId, string amount) + signal transferShieldedRequested(string fromAccountId, string toKeysJsonOrAddress, string amount) + signal transferShieldedOwnedRequested(string fromAccountId, string toAccountId, string amount) + signal transferDeshieldedRequested(string fromAccountId, string toAccountId, string amount) signal copyRequested(string copyText) readonly property int fromFilterCount: fromCombo.count + readonly property int toFilterCount: toCombo.count QtObject { id: d property bool useOwnedAccountForTo: false + readonly property bool isPublicTab: transferTypeBar.currentIndex === 0 readonly property bool isPrivateTab: transferTypeBar.currentIndex === 1 - readonly property bool toAddressValid: isPrivateTab && useOwnedAccountForTo - ? (fromFilterCount > 0 && toCombo.currentIndex >= 0) + readonly property bool isShieldedTab: transferTypeBar.currentIndex === 2 + readonly property bool isDeshieldedTab: transferTypeBar.currentIndex === 3 + readonly property bool showOwnedOption: true + readonly property bool toAddressValid: useOwnedAccountForTo + ? (toFilterCount > 0 && toCombo.currentIndex >= 0) : (toField && toField.text.trim().length > 0) - readonly property bool sendEnabled: amountField && manualFromField + readonly property bool needsProof: isPrivateTab || isShieldedTab || isDeshieldedTab + readonly property bool sendEnabled: !root.transferPending + && amountField && manualFromField && amountField.text.length > 0 && d.toAddressValid && ((fromFilterCount > 0 && fromCombo.currentIndex >= 0) || (fromFilterCount === 0 && manualFromField.text.trim().length > 0)) @@ -54,7 +68,7 @@ Rectangle { // Transfer type toggle TabBar { id: transferTypeBar - Layout.preferredWidth: 200 + Layout.preferredWidth: 400 currentIndex: 0 background: Rectangle { @@ -69,6 +83,14 @@ Rectangle { LogosTabButton { text: qsTr("Private") } + + LogosTabButton { + text: qsTr("Shielded") + } + + LogosTabButton { + text: qsTr("Deshielded") + } } // From: dropdown when accounts exist, or manual entry when list is empty @@ -85,14 +107,14 @@ Rectangle { LogosTextField { id: manualFromField Layout.fillWidth: true - placeholderText: qsTr("Paste or type from address") + placeholderText: qsTr("Paste or type from account ID") visible: fromFilterCount === 0 } AccountComboBox { id: fromCombo Layout.fillWidth: true - model: fromAccountModel + model: (d.isPrivateTab || d.isDeshieldedTab) ? root.privateAccountModel : root.publicAccountModel visible: fromFilterCount > 0 onCopyRequested: (text) => root.copyRequested(text) } @@ -109,28 +131,26 @@ Rectangle { color: Theme.palette.textSecondary } - CheckBox { + LogosCheckbox { id: useOwnedToCheck - visible: d.isPrivateTab + visible: d.showOwnedOption checked: d.useOwnedAccountForTo onCheckedChanged: d.useOwnedAccountForTo = checked text: qsTr("Use owned account") - font.pixelSize: Theme.typography.secondaryText - palette.text: Theme.palette.text } LogosTextField { id: toField Layout.fillWidth: true - placeholderText: qsTr("Recipient public key") - visible: !d.isPrivateTab || !d.useOwnedAccountForTo + placeholderText: (d.isPublicTab || d.isDeshieldedTab) ? qsTr("Recipient account ID") : qsTr("Recipient public keys (JSON)") + visible: !d.useOwnedAccountForTo } AccountComboBox { id: toCombo Layout.fillWidth: true - model: fromAccountModel - visible: d.isPrivateTab && d.useOwnedAccountForTo && fromFilterCount > 0 + model: (d.isPublicTab || d.isDeshieldedTab) ? root.publicAccountModel : root.privateAccountModel + visible: d.useOwnedAccountForTo && toFilterCount > 0 onCopyRequested: (text) => root.copyRequested(text) } } @@ -162,25 +182,56 @@ Rectangle { onClicked: { var fromId = fromFilterCount > 0 && fromCombo.currentIndex >= 0 ? (fromCombo.currentValue ?? "") - : manualFromField.text.trim() - var toAddress = d.useOwnedAccountForTo && toCombo.currentIndex >= 0 + : Base58.decode(manualFromField.text.trim()) + var rawTo = toField.text.trim() + var toAddress = (d.useOwnedAccountForTo && toCombo.currentIndex >= 0) ? (toCombo.currentValue ?? "") - : toField.text.trim() + : (rawTo.startsWith("{") ? rawTo : Base58.decode(rawTo)) var amount = amountField.text.trim() if (fromId.length > 0 && toAddress.length > 0 && amount.length > 0) { - if (transferTypeBar.currentIndex === 0) + if (d.isPublicTab) root.transferPublicRequested(fromId, toAddress, amount) - else if (d.useOwnedAccountForTo) - root.transferPrivateOwnedRequested(fromId, toAddress, amount) - else - root.transferPrivateRequested(fromId, toAddress, amount) + else if (d.isPrivateTab) { + if (d.useOwnedAccountForTo) + root.transferPrivateOwnedRequested(fromId, toAddress, amount) + else + root.transferPrivateRequested(fromId, toAddress, amount) + } else if (d.isShieldedTab) { + if (d.useOwnedAccountForTo) + root.transferShieldedOwnedRequested(fromId, toAddress, amount) + else + root.transferShieldedRequested(fromId, toAddress, amount) + } else if (d.isDeshieldedTab) { + root.transferDeshieldedRequested(fromId, toAddress, amount) + } } } } + // Proof pending indicator + RowLayout { + Layout.fillWidth: true + visible: root.transferPending + spacing: Theme.spacing.small + + LogosSpinner { + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + running: root.transferPending + } + + LogosText { + Layout.fillWidth: true + text: qsTr("Generating proof, please wait…") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + } + // Result label RowLayout { Layout.fillWidth: true + visible: !root.transferPending LogosText { id: resultText Layout.fillWidth: true @@ -195,7 +246,7 @@ Rectangle { Layout.alignment: Qt.AlignRight Layout.preferredHeight: 40 Layout.preferredWidth: 40 - onCopyText: root.copyRequested(root.transferResult) + onCopyText: root.copyRequested(root.transferTxHash || root.transferResult) visible: resultText.text } }