Merge pull request #18 from logos-blockchain/schouhy/wallet-improvements

feat: wallet improvements
This commit is contained in:
Sergio Chouhy 2026-06-15 16:52:36 -03:00 committed by GitHub
commit bd36ed9002
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 519 additions and 85 deletions

View File

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

View File

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

View File

@ -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<int, QByteArray> 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:

View File

@ -4,13 +4,19 @@
#include <QClipboard>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
#include <QTimer>
#include <QUrl>
#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<quint64>(currentBlockHeight());
if (m_syncTarget == 0 || static_cast<quint64>(lastSyncedBlock()) >= m_syncTarget) {
updateBalances();
return;
}
m_syncing = true;
syncNextChunk();
}
void LEZWalletBackend::syncNextChunk()
{
const quint64 synced = static_cast<quint64>(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;
}

View File

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

View File

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

71
src/qml/Base58.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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