Merge pull request #20 from logos-blockchain/schouhy/add-withdrawal-tab

Add withdrawal tab
This commit is contained in:
Sergio Chouhy 2026-06-25 11:34:17 -03:00 committed by GitHub
commit b343b48ef9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1485 additions and 411 deletions

View File

@ -20,6 +20,8 @@ logos_module(
src/LEZWalletAccountModel.cpp
src/LEZAccountFilterModel.h
src/LEZAccountFilterModel.cpp
src/LEZClaimableAccountFilterModel.h
src/LEZClaimableAccountFilterModel.cpp
FIND_PACKAGES
Qt6Gui
LINK_LIBRARIES

722
flake.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx";
logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module";
logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=b27f7b4e600e9041694e8cd29c03579ccdab9fbb";
};
outputs = inputs@{ logos-module-builder, ... }:

View File

@ -0,0 +1,19 @@
#include "LEZClaimableAccountFilterModel.h"
LEZClaimableAccountFilterModel::LEZClaimableAccountFilterModel(QObject* parent)
: QSortFilterProxyModel(parent)
{
connect(this, &QAbstractItemModel::rowsInserted, this, &LEZClaimableAccountFilterModel::countChanged);
connect(this, &QAbstractItemModel::rowsRemoved, this, &LEZClaimableAccountFilterModel::countChanged);
connect(this, &QAbstractItemModel::modelReset, this, &LEZClaimableAccountFilterModel::countChanged);
connect(this, &QAbstractItemModel::layoutChanged, this, &LEZClaimableAccountFilterModel::countChanged);
}
bool LEZClaimableAccountFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const
{
if (!sourceModel())
return false;
const QModelIndex idx = sourceModel()->index(sourceRow, 0, sourceParent);
const QString vaultBalance = sourceModel()->data(idx, LEZWalletAccountModel::VaultBalanceRole).toString();
return !vaultBalance.isEmpty() && vaultBalance != QStringLiteral("0");
}

View File

@ -0,0 +1,22 @@
#pragma once
#include <QSortFilterProxyModel>
#include "LEZWalletAccountModel.h"
// Filters the account model down to accounts with a nonzero claimable vault balance
// (see LEZWalletBackend::refreshVaultBalances), for the Bridge tab's Claim Deposit list.
class LEZClaimableAccountFilterModel : public QSortFilterProxyModel {
Q_OBJECT
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
explicit LEZClaimableAccountFilterModel(QObject* parent = nullptr);
int count() const { return rowCount(); }
protected:
bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override;
signals:
void countChanged();
};

View File

@ -1,5 +1,8 @@
#include "LEZWalletAccountModel.h"
#include <QJsonObject>
#include <QHash>
#include <QPair>
#include <QVariantMap>
#include <algorithm>
LEZWalletAccountModel::LEZWalletAccountModel(QObject* parent)
: QAbstractListModel(parent)
@ -23,7 +26,11 @@ QVariant LEZWalletAccountModel::data(const QModelIndex& index, int role) const
case NameRole: return e.name;
case AccountIdRole: return e.accountId;
case BalanceRole: return e.balance;
case VaultBalanceRole: return e.vaultBalance;
case IsPublicRole: return e.isPublic;
case SectionKeyRole: return e.sectionKey;
case KeysJsonRole: return e.keysJson;
case IsFirstInGroupRole: return e.isFirstInGroup;
default: return QVariant();
}
}
@ -34,53 +41,63 @@ QHash<int, QByteArray> LEZWalletAccountModel::roleNames() const
{ NameRole, "name" },
{ AccountIdRole, "accountId" },
{ BalanceRole, "balance" },
{ IsPublicRole, "isPublic" }
{ VaultBalanceRole, "vaultBalance" },
{ IsPublicRole, "isPublic" },
{ SectionKeyRole, "sectionKey" },
{ KeysJsonRole, "keysJson" },
{ IsFirstInGroupRole, "isFirstInGroup" }
};
}
void LEZWalletAccountModel::replaceFromJsonArray(const QJsonArray& arr)
{
beginResetModel();
int oldCount = m_entries.size();
m_entries.clear();
for (const QJsonValue& v : arr) {
LEZWalletAccountEntry e;
e.balance = QString();
if (v.isObject()) {
const QJsonObject obj = v.toObject();
e.accountId = obj.value(QStringLiteral("account_id")).toString();
e.isPublic = obj.value(QStringLiteral("is_public")).toBool(true);
} else {
e.accountId = v.toString();
e.isPublic = true;
}
e.name = QString();
m_entries.append(e);
}
endResetModel();
if (oldCount != m_entries.size())
emit countChanged();
}
void LEZWalletAccountModel::replaceFromVariantList(const QVariantList& list)
{
// Rebuilding from scratch loses any balance/vaultBalance already fetched for an
// account that's still present — carry those over so periodic re-listing (e.g. to
// pick up newly discovered private accounts) doesn't make the claimable list and
// balances flicker empty until the next refresh repopulates them.
QHash<QString, QPair<QString, QString>> previousBalances;
previousBalances.reserve(m_entries.size());
for (const LEZWalletAccountEntry& e : m_entries)
previousBalances.insert(e.accountId, qMakePair(e.balance, e.vaultBalance));
beginResetModel();
int oldCount = m_entries.size();
m_entries.clear();
for (const QVariant& v : list) {
LEZWalletAccountEntry e;
e.balance = QString();
if (v.canConvert<QVariantMap>()) {
const QVariantMap obj = v.toMap();
e.accountId = obj.value(QStringLiteral("account_id")).toString();
e.isPublic = obj.value(QStringLiteral("is_public"), true).toBool();
e.name = QString();
if (v.type() == QVariant::Map) {
const QVariantMap map = v.toMap();
e.accountId = map.value(QStringLiteral("account_id")).toString();
e.isPublic = map.value(QStringLiteral("is_public"), true).toBool();
if (e.isPublic) {
e.sectionKey = PublicSectionKey;
} else {
e.sectionKey = map.value(QStringLiteral("npk")).toString();
e.keysJson = map.value(QStringLiteral("keys_json")).toString();
}
} else {
e.accountId = v.toString();
e.isPublic = true;
e.sectionKey = PublicSectionKey;
}
const auto previous = previousBalances.find(e.accountId);
if (previous != previousBalances.end()) {
e.balance = previous->first;
e.vaultBalance = previous->second;
}
e.name = QString();
m_entries.append(e);
}
// Keep entries grouped by section (public first) so consecutive rows of the same
// group are contiguous, then mark each group's first row for the QML header.
std::stable_sort(m_entries.begin(), m_entries.end(),
[](const LEZWalletAccountEntry& a, const LEZWalletAccountEntry& b) {
if (a.isPublic != b.isPublic) return a.isPublic;
return a.sectionKey < b.sectionKey;
});
for (int i = 0; i < m_entries.size(); ++i)
m_entries[i].isFirstInGroup = (i == 0) || (m_entries[i].sectionKey != m_entries[i - 1].sectionKey);
endResetModel();
if (oldCount != m_entries.size())
emit countChanged();
@ -99,3 +116,26 @@ void LEZWalletAccountModel::setBalanceByAccountId(const QString& accountId, cons
}
}
}
void LEZWalletAccountModel::setVaultBalanceByAccountId(const QString& accountId, const QString& vaultBalance)
{
for (int i = 0; i < m_entries.size(); ++i) {
if (m_entries.at(i).accountId == accountId) {
if (m_entries.at(i).vaultBalance != vaultBalance) {
m_entries[i].vaultBalance = vaultBalance;
QModelIndex idx = index(i, 0);
emit dataChanged(idx, idx, { VaultBalanceRole });
}
return;
}
}
}
bool LEZWalletAccountModel::isPublicAccount(const QString& accountId, bool defaultValue) const
{
for (const LEZWalletAccountEntry& e : m_entries) {
if (e.accountId == accountId)
return e.isPublic;
}
return defaultValue;
}

View File

@ -1,17 +1,30 @@
#pragma once
#include <QAbstractListModel>
#include <QJsonArray>
#include <QVariant>
#include <QString>
#include <QVariantList>
// Public accounts have no key group, so they all share the PublicSectionKey section.
// Private accounts section by NPK (the key group they belong to) — see
// LEZWalletBackend::buildEnrichedAccountList, which attaches "npk"/"keys_json" per entry.
inline const QString PublicSectionKey = QStringLiteral("public");
struct LEZWalletAccountEntry {
QString name;
QString accountId;
QString balance;
QString vaultBalance; // claimable balance held in this account's bridge vault PDA
bool isPublic = true;
QString sectionKey;
QString keysJson; // {nullifier_public_key, viewing_public_key} shared by the whole section; private only
bool isFirstInGroup = false; // QML renders the section header above rows where this is true
};
// Note: this model is exposed to QML via Qt Remote Objects model replication (see
// logos.model() in ExecutionZoneWalletView.qml), which only replicates roles — not
// arbitrary Q_PROPERTYs. Anything QML needs must be a role on the row, not a property
// on the model itself.
class LEZWalletAccountModel : public QAbstractListModel {
Q_OBJECT
Q_PROPERTY(int count READ count NOTIFY countChanged)
@ -20,7 +33,11 @@ public:
NameRole = Qt::UserRole + 1,
AccountIdRole,
BalanceRole,
IsPublicRole
VaultBalanceRole,
IsPublicRole,
SectionKeyRole,
KeysJsonRole,
IsFirstInGroupRole
};
Q_ENUM(Role)
@ -30,11 +47,17 @@ public:
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
void replaceFromJsonArray(const QJsonArray& arr);
void replaceFromVariantList(const QVariantList& list);
void setBalanceByAccountId(const QString& accountId, const QString& balance);
void setVaultBalanceByAccountId(const QString& accountId, const QString& vaultBalance);
int count() const { return m_entries.size(); }
// Authoritative isPublic lookup by account ID — used to validate/derive the flag
// server-side instead of trusting a caller-supplied value, since this model is the
// source of truth for which accounts the wallet actually owns. Falls back to
// `defaultValue` if the account isn't found.
bool isPublicAccount(const QString& accountId, bool defaultValue = true) const;
signals:
void countChanged();

View File

@ -8,7 +8,6 @@
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
@ -30,6 +29,10 @@ namespace {
// Timeout(-1) means "wait indefinitely", matching Qt's own convention
// for infinite waits (e.g. QRemoteObjectPendingCall::waitForFinished(-1)).
const Timeout NO_TIMEOUT{-1};
// Warm-up retry budget for openIfPathsConfigured(), 50ms x up to 100 attempts
// ~= 5s, matching LogosAPIConsumer's own connect-retry budget.
const int MODULE_WARMUP_RETRY_MS = 50;
const int MODULE_WARMUP_MAX_ATTEMPTS = 100;
// Convert a decimal amount string to 32-char hex (16 bytes little-endian)
// for transfer_public/transfer_private/transfer_private_owned.
@ -61,12 +64,14 @@ LEZWalletBackend::LEZWalletBackend(LogosAPI* logosAPI, QObject* parent)
m_accountModel(new LEZWalletAccountModel(this)),
m_filteredAccountModel(new LEZAccountFilterModel(this)),
m_privateAccountModel(new LEZAccountFilterModel(this)),
m_claimableAccountModel(new LEZClaimableAccountFilterModel(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);
m_claimableAccountModel->setSourceModel(m_accountModel);
// Initialise PROP defaults via the generated setters.
setIsWalletOpen(false);
@ -118,29 +123,74 @@ void LEZWalletBackend::persistStoragePath(const QString& path)
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(STORAGE_PATH_KEY, localPath);
}
void LEZWalletBackend::openIfPathsConfigured()
void LEZWalletBackend::openIfPathsConfigured(int attempt)
{
if (configPath().isEmpty() || storagePath().isEmpty()) return;
// The first cross-process call this module makes to logos_execution_zone can race
// the inter-module capability/auth-token handshake: if it goes out before the
// target has been informed of our token, ModuleProxy rejects it and the call
// resolves to a default-constructed return value -- 0 for open()'s int64_t, which
// is indistinguishable from WALLET_FFI_SUCCESS. Warm up with a harmless,
// wallet-state-free call first: version() defaults to "" on that same rejection,
// which IS distinguishable from its real non-empty value, so we can retry here
// until the handshake has settled before trusting open()'s result. Once any call
// to logos_execution_zone succeeds, its token is cached for the rest of the
// session, so open() itself won't hit this race afterwards.
if (m_logos->logos_execution_zone.version().isEmpty() && attempt < MODULE_WARMUP_MAX_ATTEMPTS) {
QTimer::singleShot(MODULE_WARMUP_RETRY_MS, this,
[this, attempt]() { openIfPathsConfigured(attempt + 1); });
return;
}
qDebug() << "LEZWalletBackend: opening wallet with config" << configPath()
<< "storage" << storagePath();
int err = m_logos->logos_execution_zone.open(configPath(), storagePath());
if (err == WALLET_FFI_SUCCESS) {
qDebug() << "LEZWalletBackend: wallet opened successfully";
setIsWalletOpen(true);
m_accountModel->replaceFromVariantList(m_logos->logos_execution_zone.list_accounts());
fetchAndUpdateBlockHeights();
startChunkedSync();
refreshSequencerAddr();
finishOpeningWallet();
} else {
qWarning() << "LEZWalletBackend: failed to open wallet, error" << err
<< "config:" << configPath() << "storage:" << storagePath();
}
}
// Tags each private account with the NPK of the key group it belongs to (plus that
// group's {nullifier_public_key, viewing_public_key} JSON), so the model can section
// accounts by key group instead of listing them flat. Public accounts are untouched.
QVariantList LEZWalletBackend::buildEnrichedAccountList()
{
QVariantList raw = m_logos->logos_execution_zone.list_accounts();
QVariantList enriched;
enriched.reserve(raw.size());
for (const QVariant& v : raw) {
QVariantMap map = v.toMap();
if (!map.value(QStringLiteral("is_public"), true).toBool()) {
const QString accountId = map.value(QStringLiteral("account_id")).toString();
const QString keysJson = getPrivateAccountKeys(accountId);
const QJsonDocument doc = QJsonDocument::fromJson(keysJson.toUtf8());
if (doc.isObject()) {
map[QStringLiteral("npk")] = doc.object().value(QStringLiteral("nullifier_public_key")).toString();
map[QStringLiteral("keys_json")] = keysJson;
}
}
enriched.append(map);
}
return enriched;
}
void LEZWalletBackend::finishOpeningWallet()
{
setIsWalletOpen(true);
m_accountModel->replaceFromVariantList(buildEnrichedAccountList());
fetchAndUpdateBlockHeights();
startChunkedSync();
refreshSequencerAddr();
}
void LEZWalletBackend::refreshAccounts()
{
m_accountModel->replaceFromVariantList(m_logos->logos_execution_zone.list_accounts());
m_accountModel->replaceFromVariantList(buildEnrichedAccountList());
fetchAndUpdateBlockHeights();
if (!m_syncing)
startChunkedSync();
@ -169,6 +219,9 @@ void LEZWalletBackend::syncNextChunk()
const quint64 synced = static_cast<quint64>(lastSyncedBlock());
if (synced >= m_syncTarget) {
m_syncing = false;
// Sync may have discovered new private accounts (e.g. shielded transfers to a
// foreign NPK/VPK); re-list so the model picks them up without a restart.
m_accountModel->replaceFromVariantList(buildEnrichedAccountList());
fetchAndUpdateBlockHeights();
updateBalances();
return;
@ -333,6 +386,50 @@ QString LEZWalletBackend::transferDeshielded(QString fromHex, QString toHex, QSt
NO_TIMEOUT).toString();
}
QString LEZWalletBackend::bridgeWithdraw(QString fromHex, QString bedrockAccountPkHex, quint64 amount)
{
return m_logos->logos_execution_zone.bridge_withdraw(fromHex, bedrockAccountPkHex, amount);
}
QString LEZWalletBackend::getVaultBalance(const QString& accountIdHex)
{
return m_logos->logos_execution_zone.get_vault_balance(accountIdHex);
}
void LEZWalletBackend::refreshVaultBalances()
{
if (!m_accountModel) return;
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::AccountIdRole).toString();
const QString vaultBal = getVaultBalance(addr);
if (!vaultBal.isEmpty())
m_accountModel->setVaultBalanceByAccountId(addr, vaultBal);
}
}
QString LEZWalletBackend::vaultClaim(QString fromHex, bool isPublic, QString amountStr)
{
const QString amountHex = amountToLe16Hex(amountStr);
if (amountHex.isEmpty()) return QStringLiteral("Error: Invalid amount.");
// Don't trust the caller-supplied isPublic — the account model is the source of
// truth for which accounts the wallet owns and whether each is public or private.
const bool actuallyPublic = m_accountModel
? m_accountModel->isPublicAccount(fromHex, isPublic)
: isPublic;
if (actuallyPublic)
return m_logos->logos_execution_zone.vault_claim(fromHex, amountHex);
// vault_claim_private generates a proof, like transfer_private/transfer_shielded
// above — go through invokeRemoteMethod with NO_TIMEOUT instead of the generated
// accessor, which applies the SDK's default 20s Timeout and returns before the
// proof is actually done and the tx submitted.
return m_logosAPI->getClient(LEZ_MODULE)->invokeRemoteMethod(
LEZ_MODULE, "vault_claim_private",
QVariantList{fromHex.trimmed(), amountHex},
NO_TIMEOUT).toString();
}
void LEZWalletBackend::applySequencerAddrToConfig(const QString& configPath, const QString& sequencerAddr)
{
QJsonObject obj;
@ -354,24 +451,40 @@ void LEZWalletBackend::applySequencerAddrToConfig(const QString& configPath, con
file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
}
bool LEZWalletBackend::createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr)
QString LEZWalletBackend::createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr)
{
const QString localConfigPath = toLocalPath(configPath);
const QString localStoragePath = toLocalPath(storagePath);
// Both files already on disk: this is most likely an existing wallet the
// user pointed us at (e.g. from the setup screen), not a request to
// overwrite it. Try to load it instead of blindly creating a new one.
if (QFile::exists(localConfigPath) && QFile::exists(localStoragePath)) {
int err = m_logos->logos_execution_zone.open(localConfigPath, localStoragePath);
if (err != WALLET_FFI_SUCCESS) {
return QStringLiteral(
"Could not load the wallet at the selected paths. Pick "
"different existing config/storage files, or choose paths "
"that don't exist yet to create a new wallet there.");
}
persistConfigPath(localConfigPath);
persistStoragePath(localStoragePath);
finishOpeningWallet();
return QString();
}
if (!sequencerAddr.isEmpty())
applySequencerAddrToConfig(localConfigPath, sequencerAddr);
const QString mnemonic = m_logos->logos_execution_zone.create_new(
localConfigPath, localStoragePath, password);
if (mnemonic.isEmpty()) return false;
if (mnemonic.isEmpty())
return QStringLiteral("Failed to create wallet. Check paths and try again.");
persistConfigPath(localConfigPath);
persistStoragePath(localStoragePath);
setIsWalletOpen(true);
refreshAccounts();
refreshSequencerAddr();
return true;
finishOpeningWallet();
return QString();
}
void LEZWalletBackend::copyToClipboard(QString text)

View File

@ -7,6 +7,7 @@
#include "rep_LEZWalletBackend_source.h"
#include "LEZAccountFilterModel.h"
#include "LEZClaimableAccountFilterModel.h"
#include "LEZWalletAccountModel.h"
class LogosAPI;
@ -20,6 +21,7 @@ class LEZWalletBackend : public LEZWalletBackendSimpleSource {
Q_PROPERTY(LEZWalletAccountModel* accountModel READ accountModel CONSTANT)
Q_PROPERTY(LEZAccountFilterModel* filteredAccountModel READ filteredAccountModel CONSTANT)
Q_PROPERTY(LEZAccountFilterModel* privateAccountModel READ privateAccountModel CONSTANT)
Q_PROPERTY(LEZClaimableAccountFilterModel* claimableAccountModel READ claimableAccountModel CONSTANT)
public:
explicit LEZWalletBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
@ -28,6 +30,7 @@ public:
LEZWalletAccountModel* accountModel() const { return m_accountModel; }
LEZAccountFilterModel* filteredAccountModel() const { return m_filteredAccountModel; }
LEZAccountFilterModel* privateAccountModel() const { return m_privateAccountModel; }
LEZClaimableAccountFilterModel* claimableAccountModel() const { return m_claimableAccountModel; }
public slots:
// Overrides of the pure-virtual slots generated from the .rep.
@ -45,7 +48,10 @@ public slots:
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;
QString bridgeWithdraw(QString fromHex, QString bedrockAccountPkHex, quint64 amount) override;
void refreshVaultBalances() override;
QString vaultClaim(QString fromHex, bool isPublic, QString amountStr) override;
QString createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr) override;
void copyToClipboard(QString text) override;
private slots:
@ -57,11 +63,14 @@ private:
void applySequencerAddrToConfig(const QString& configPath, const QString& sequencerAddr);
void fetchAndUpdateBlockHeights();
void startChunkedSync();
QVariantList buildEnrichedAccountList();
void updateBalances();
QString getVaultBalance(const QString& accountIdHex);
void refreshSequencerAddr();
void saveWallet();
void openIfPathsConfigured();
void openIfPathsConfigured(int attempt = 0);
void finishOpeningWallet();
bool m_syncing = false;
quint64 m_syncTarget = 0;
@ -70,6 +79,7 @@ private:
LEZWalletAccountModel* m_accountModel;
LEZAccountFilterModel* m_filteredAccountModel;
LEZAccountFilterModel* m_privateAccountModel;
LEZClaimableAccountFilterModel* m_claimableAccountModel;
LogosAPI* m_logosAPI;
LogosModules* m_logos;

View File

@ -23,7 +23,12 @@ class LEZWalletBackend
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, QString sequencerAddr))
SLOT(QString bridgeWithdraw(QString fromHex, QString bedrockAccountPkHex, quint64 amount))
SLOT(void refreshVaultBalances())
SLOT(QString vaultClaim(QString fromHex, bool isPublic, QString amountStr))
SLOT(QString createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr))
SLOT(void copyToClipboard(QString text))
}

View File

@ -13,6 +13,7 @@ Rectangle {
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")
readonly property var claimableAccountModel: logos.model("lez_wallet_ui", "claimableAccountModel")
property bool ready: false
Connections {
@ -118,10 +119,13 @@ Rectangle {
configPath: backend ? backend.configPath : ""
onCreateWallet: function(configPath, storagePath, password, sequencerUrl) {
if (!backend) return
// createNew() returns an empty string on success, or a
// human-readable error message (e.g. existing files at
// the chosen paths failed to load) otherwise.
logos.watch(backend.createNew(configPath, storagePath, password, sequencerUrl),
function(ok) {
if (!ok)
createError = qsTr("Failed to create wallet. Check paths and try again.")
function(errorMessage) {
if (errorMessage)
createError = errorMessage
},
function(error) {
createError = qsTr("Error creating wallet: %1").arg(error)
@ -138,6 +142,7 @@ Rectangle {
accountModel: root.accountModel
publicAccountModel: root.publicAccountModel
privateAccountModel: root.privateAccountModel
claimableAccountModel: root.claimableAccountModel
lastSyncedBlock: backend ? backend.lastSyncedBlock : 0
currentBlockHeight: backend ? backend.currentBlockHeight : 0
@ -229,21 +234,49 @@ Rectangle {
dashboardView.transferTxHash = ""
})
}
onBridgeWithdrawRequested: (fromId, bedrockAccountPkHex, amount) => {
if (!backend) return
var parsedAmount = Number(amount)
if (!Number.isInteger(parsedAmount) || parsedAmount <= 0) {
dashboardView.transferResult = qsTr("Error: Invalid amount.")
dashboardView.transferResultIsError = true
dashboardView.transferTxHash = ""
return
}
logos.watch(backend.bridgeWithdraw(fromId, bedrockAccountPkHex, parsedAmount),
function(raw) { ffiErrors.applyTransferResult(dashboardView, raw) },
function(error) {
dashboardView.transferResult = qsTr("Error: %1").arg(error)
dashboardView.transferResultIsError = true
dashboardView.transferTxHash = ""
})
}
onVaultClaimRequested: (fromId, isPublic, amount) => {
if (!backend) return
dashboardView.transferPending = !isPublic
logos.watch(backend.vaultClaim(fromId, isPublic, amount),
function(raw) {
dashboardView.transferPending = false
ffiErrors.applyTransferResult(dashboardView, raw)
backend.refreshVaultBalances()
backend.refreshBalances()
},
function(error) {
dashboardView.transferPending = false
dashboardView.transferResult = qsTr("Error: %1").arg(error)
dashboardView.transferResultIsError = true
dashboardView.transferTxHash = ""
})
}
onRefreshClaimableDepositsRequested: {
if (!backend) return
backend.refreshVaultBalances() // void slot, fire-and-forget
}
onCopyRequested: (copyText) => {
clipHelper.text = copyText
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

@ -12,7 +12,6 @@ ComboBox {
// Forwarded from AccountDelegate's copy button bubble up to the parent
// view, which calls backend.copyToClipboard().
signal copyRequested(string text)
signal copyPublicKeysRequested(string accountIdHex)
leftPadding: 12
rightPadding: 12
@ -62,7 +61,6 @@ ComboBox {
width: root.popup ? (root.popup.width - root.popup.leftPadding - root.popup.rightPadding) : 368
highlighted: root.highlightedIndex === index
onCopyRequested: (text) => root.copyRequested(text)
onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id)
}
popup: Popup {

View File

@ -14,7 +14,6 @@ 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
@ -78,9 +77,7 @@ ItemDelegate {
LogosCopyButton {
Layout.preferredHeight: 40
Layout.preferredWidth: 40
onCopyText: model.isPublic
? root.copyRequested(Base58.encode(model.accountId ?? ""))
: root.copyPublicKeysRequested(model.accountId ?? "")
onCopyText: root.copyRequested(Base58.encode(model.accountId ?? ""))
visible: addressLabel.text
icon.color: Theme.palette.textMuted
}

View File

@ -21,7 +21,6 @@ Rectangle {
signal createPrivateAccountRequested()
signal fetchBalancesRequested()
signal copyRequested(string text)
signal copyPublicKeysRequested(string accountIdHex)
radius: Theme.spacing.radiusXlarge
color: Theme.palette.backgroundSecondary
@ -63,7 +62,7 @@ Rectangle {
// Sync progress
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.xsmall
spacing: Theme.spacing.small
visible: root.currentBlockHeight > 0 && root.lastSyncedBlock < root.currentBlockHeight
RowLayout {
@ -127,10 +126,54 @@ Rectangle {
spacing: Theme.spacing.small
model: root.accountModel
delegate: AccountDelegate {
// Each private account's "keysJson"/"sectionKey"/"isFirstInGroup" are plain
// model roles (replicated like any other row data), so the group header for
// the section a row starts is rendered inline here rather than via
// ListView.section section.delegate only gets the section's string value,
// with no way back to that row's data once the model is a remote replica.
delegate: ColumnLayout {
width: listView.width
onCopyRequested: (text) => root.copyRequested(text)
onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id)
spacing: Theme.spacing.small
// keysJson is only ever read inside the copy button's click handler below,
// never bound to anything visible so unlike accountId/isPublic/isFirstInGroup
// (which are warmed up by their visible bindings), the Qt Remote Objects model
// replica never requests this role from the source process until something
// actually reads it. Without this binding, the first click reads a not-yet-
// fetched (empty) value and the real data only arrives in time for the next
// click. Referencing it here forces the role to be requested as soon as the
// row is created.
property string keysJsonWarm: model.keysJson ?? ""
RowLayout {
Layout.fillWidth: true
visible: model.isFirstInGroup ?? false
spacing: Theme.spacing.small
LogosText {
text: model.isPublic
? qsTr("Public Accounts")
: qsTr("Private")
font.pixelSize: Theme.typography.secondaryText
font.bold: true
color: Theme.palette.textSecondary
}
Item { Layout.fillWidth: true }
LogosCopyButton {
Layout.preferredHeight: 32
Layout.preferredWidth: 32
visible: !model.isPublic
icon.color: Theme.palette.textMuted
onCopyText: root.copyRequested(model.keysJson ?? "")
}
}
AccountDelegate {
Layout.fillWidth: true
onCopyRequested: (text) => root.copyRequested(text)
}
}
}

View File

@ -0,0 +1,69 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
Item {
id: root
// --- Public API: data in ---
property var publicAccountModel: null
property var claimableAccountModel: null
property bool transferPending: false
// --- Public API: signals out ---
signal bridgeWithdrawRequested(string fromAccountId, string bedrockAccountPkHex, string amount)
signal vaultClaimRequested(string fromAccountId, bool isPublic, string amount)
signal refreshClaimableDepositsRequested()
signal copyRequested(string copyText)
ColumnLayout {
anchors.fill: parent
spacing: Theme.spacing.large
// Bridge section toggle
TabBar {
id: bridgeSectionBar
Layout.fillWidth: true
spacing: Theme.spacing.small
currentIndex: 0
background: Rectangle {
color: Theme.palette.backgroundSecondary
radius: Theme.spacing.radiusSmall
}
LogosTabButton {
text: qsTr("Withdraw")
}
LogosTabButton {
text: qsTr("Claim Deposit")
}
}
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: bridgeSectionBar.currentIndex
WithdrawPanel {
publicAccountModel: root.publicAccountModel
transferPending: root.transferPending
onBridgeWithdrawRequested: (fromId, bedrockAccountPkHex, amount) => root.bridgeWithdrawRequested(fromId, bedrockAccountPkHex, amount)
onCopyRequested: (copyText) => root.copyRequested(copyText)
}
ClaimDepositPanel {
claimableAccountModel: root.claimableAccountModel
claimPending: root.transferPending
onVaultClaimRequested: (fromId, isPublic, amount) => root.vaultClaimRequested(fromId, isPublic, amount)
onRefreshRequested: root.refreshClaimableDepositsRequested()
}
}
}
}

View File

@ -0,0 +1,98 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
import "../Base58.js" as Base58
Item {
id: root
// --- Public API: data in ---
property var claimableAccountModel: null
property bool claimPending: false
// --- Public API: signals out ---
signal vaultClaimRequested(string fromAccountId, bool isPublic, string amount)
signal refreshRequested()
onVisibleChanged: if (visible) root.refreshRequested()
ColumnLayout {
anchors.fill: parent
spacing: Theme.spacing.large
LogosText {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.topMargin: Theme.spacing.xlarge
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: qsTr("No deposits pending claim")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
visible: !listView.visible
}
ListView {
id: listView
Layout.fillWidth: true
Layout.fillHeight: true
visible: count > 0
clip: true
spacing: Theme.spacing.small
model: root.claimableAccountModel
delegate: ItemDelegate {
id: delegateRoot
width: listView.width
leftPadding: Theme.spacing.medium
rightPadding: Theme.spacing.medium
topPadding: Theme.spacing.medium
bottomPadding: Theme.spacing.medium
background: Rectangle {
color: delegateRoot.hovered ? Theme.palette.backgroundMuted : Theme.palette.backgroundTertiary
radius: Theme.spacing.radiusLarge
}
contentItem: RowLayout {
spacing: Theme.spacing.medium
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.tiny
LogosText {
text: model.name || (qsTr("Account ") + Base58.encode(model.accountId ?? "").slice(0, 4))
font.pixelSize: Theme.typography.secondaryText
font.bold: true
}
LogosText {
text: qsTr("Claimable: %1").arg(model.vaultBalance ?? "0")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
}
LogosButton {
text: qsTr("Claim")
enabled: !root.claimPending
onClicked: root.vaultClaimRequested(model.accountId, model.isPublic, model.vaultBalance)
}
}
}
}
LogosButton {
Layout.fillWidth: true
text: qsTr("Refresh")
enabled: !root.claimPending
onClicked: root.refreshRequested()
}
}
}

View File

@ -12,6 +12,7 @@ Rectangle {
property var accountModel: null
property var publicAccountModel: null
property var privateAccountModel: null
property var claimableAccountModel: null
property string transferResult: ""
property string transferTxHash: ""
property bool transferResultIsError: false
@ -29,8 +30,10 @@ Rectangle {
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 bridgeWithdrawRequested(string fromAccountId, string bedrockAccountPkHex, string amount)
signal vaultClaimRequested(string fromAccountId, bool isPublic, string amount)
signal refreshClaimableDepositsRequested()
signal copyRequested(string copyText)
signal copyPublicKeysRequested(string accountIdHex)
color: Theme.palette.background
@ -52,7 +55,6 @@ Rectangle {
onCreatePrivateAccountRequested: root.createPrivateAccountRequested()
onFetchBalancesRequested: root.fetchBalancesRequested()
onCopyRequested: (text) => root.copyRequested(text)
onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id)
}
TransferPanel {
@ -61,6 +63,7 @@ Rectangle {
Layout.fillHeight: true
publicAccountModel: root.publicAccountModel
privateAccountModel: root.privateAccountModel
claimableAccountModel: root.claimableAccountModel
transferResult: root.transferResult
transferTxHash: root.transferTxHash
transferResultIsError: root.transferResultIsError
@ -72,6 +75,9 @@ Rectangle {
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)
onBridgeWithdrawRequested: (fromId, bedrockAccountPkHex, amount) => root.bridgeWithdrawRequested(fromId, bedrockAccountPkHex, amount)
onVaultClaimRequested: (fromId, isPublic, amount) => root.vaultClaimRequested(fromId, isPublic, amount)
onRefreshClaimableDepositsRequested: root.refreshClaimableDepositsRequested()
onCopyRequested: (copyText) => root.copyRequested(copyText)
}
}

View File

@ -6,7 +6,6 @@ import Logos.Theme
import Logos.Controls
import "../controls"
import "../Base58.js" as Base58
Rectangle {
id: root
@ -14,42 +13,24 @@ Rectangle {
// --- Public API: data in ---
property var publicAccountModel: null
property var privateAccountModel: null
property var claimableAccountModel: 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, transfer_shielded, transfer_shielded_owned) ---
// --- Public API: signals out (match backend: transfer_public, transfer_private, transfer_private_owned, transfer_shielded, transfer_shielded_owned, transfer_deshielded, bridge_withdraw, vault_claim) ---
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 bridgeWithdrawRequested(string fromAccountId, string bedrockAccountPkHex, string amount)
signal vaultClaimRequested(string fromAccountId, bool isPublic, string amount)
signal refreshClaimableDepositsRequested()
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 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 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))
}
radius: Theme.spacing.radiusXlarge
color: Theme.palette.backgroundSecondary
@ -58,17 +39,11 @@ Rectangle {
anchors.margins: Theme.spacing.large
spacing: Theme.spacing.large
LogosText {
text: qsTr("Transfer")
font.pixelSize: Theme.typography.titleText
font.weight: Theme.typography.weightBold
color: Theme.palette.text
}
// Transfer type toggle
// Main section toggle
TabBar {
id: transferTypeBar
Layout.preferredWidth: 400
id: mainSectionBar
Layout.fillWidth: true
spacing: Theme.spacing.small
currentIndex: 0
background: Rectangle {
@ -77,134 +52,42 @@ Rectangle {
}
LogosTabButton {
text: qsTr("Public")
text: qsTr("Transfer")
}
LogosTabButton {
text: qsTr("Private")
}
LogosTabButton {
text: qsTr("Shielded")
}
LogosTabButton {
text: qsTr("Deshielded")
text: qsTr("Bridge")
}
}
// From: dropdown when accounts exist, or manual entry when list is empty
ColumnLayout {
StackLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
Layout.fillHeight: true
currentIndex: mainSectionBar.currentIndex
LogosText {
text: qsTr("From")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
TransferTypesPanel {
publicAccountModel: root.publicAccountModel
privateAccountModel: root.privateAccountModel
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)
}
LogosTextField {
id: manualFromField
Layout.fillWidth: true
placeholderText: qsTr("Paste or type from account ID")
visible: fromFilterCount === 0
}
BridgePanel {
publicAccountModel: root.publicAccountModel
claimableAccountModel: root.claimableAccountModel
transferPending: root.transferPending
AccountComboBox {
id: fromCombo
Layout.fillWidth: true
model: (d.isPrivateTab || d.isDeshieldedTab) ? root.privateAccountModel : root.publicAccountModel
visible: fromFilterCount > 0
onCopyRequested: (text) => root.copyRequested(text)
}
}
// To field
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("To")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
LogosCheckbox {
id: useOwnedToCheck
visible: d.showOwnedOption
checked: d.useOwnedAccountForTo
onCheckedChanged: d.useOwnedAccountForTo = checked
text: qsTr("Use owned account")
}
LogosTextField {
id: toField
Layout.fillWidth: true
placeholderText: (d.isPublicTab || d.isDeshieldedTab) ? qsTr("Recipient account ID") : qsTr("Recipient public keys (JSON)")
visible: !d.useOwnedAccountForTo
}
AccountComboBox {
id: toCombo
Layout.fillWidth: true
model: (d.isPublicTab || d.isDeshieldedTab) ? root.publicAccountModel : root.privateAccountModel
visible: d.useOwnedAccountForTo && toFilterCount > 0
onCopyRequested: (text) => root.copyRequested(text)
}
}
// Amount field
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("Amount")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
LogosTextField {
id: amountField
Layout.fillWidth: true
placeholderText: "0.00"
}
}
// Send button
LogosButton {
Layout.fillWidth: true
text: qsTr("Send")
font.pixelSize: Theme.typography.secondaryText
enabled: d.sendEnabled
onClicked: {
var fromId = fromFilterCount > 0 && fromCombo.currentIndex >= 0
? (fromCombo.currentValue ?? "")
: Base58.decode(manualFromField.text.trim())
var rawTo = toField.text.trim()
var toAddress = (d.useOwnedAccountForTo && toCombo.currentIndex >= 0)
? (toCombo.currentValue ?? "")
: (rawTo.startsWith("{") ? rawTo : Base58.decode(rawTo))
var amount = amountField.text.trim()
if (fromId.length > 0 && toAddress.length > 0 && amount.length > 0) {
if (d.isPublicTab)
root.transferPublicRequested(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)
}
}
onBridgeWithdrawRequested: (fromId, bedrockAccountPkHex, amount) => root.bridgeWithdrawRequested(fromId, bedrockAccountPkHex, amount)
onVaultClaimRequested: (fromId, isPublic, amount) => root.vaultClaimRequested(fromId, isPublic, amount)
onRefreshClaimableDepositsRequested: root.refreshClaimableDepositsRequested()
onCopyRequested: (copyText) => root.copyRequested(copyText)
}
}
@ -250,9 +133,5 @@ Rectangle {
visible: resultText.text
}
}
Item {
Layout.fillHeight: true
}
}
}

View File

@ -0,0 +1,229 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
import "../controls"
import "../Base58.js" as Base58
Item {
id: root
// --- Public API: data in ---
property var publicAccountModel: null
property var privateAccountModel: null
property bool transferPending: false
// --- Public API: signals out (match backend: transfer_public, transfer_private, transfer_private_owned, transfer_shielded, transfer_shielded_owned, transfer_deshielded) ---
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 isShieldedTab: transferTypeBar.currentIndex === 2
readonly property bool isDeshieldedTab: transferTypeBar.currentIndex === 3
readonly property bool needsKeysJson: !useOwnedAccountForTo && (isPrivateTab || isShieldedTab)
readonly property bool toAddressValid: useOwnedAccountForTo
? (root.toFilterCount > 0 && toCombo.currentIndex >= 0)
: (needsKeysJson
? d.isValidKeysJson(toField && toField.text)
: (toField && toField.text.trim().length > 0))
readonly property bool needsProof: isPrivateTab || isShieldedTab || isDeshieldedTab
readonly property bool sendEnabled: !root.transferPending
&& amountField && manualFromField
&& amountField.text.length > 0 && d.toAddressValid
&& ((root.fromFilterCount > 0 && fromCombo.currentIndex >= 0)
|| (root.fromFilterCount === 0 && manualFromField.text.trim().length > 0))
// Private/shielded transfers (when not sending to an owned account) require the
// recipient's {nullifier_public_key, viewing_public_key} JSON the same JSON the
// section-header copy button produces not a bare account ID.
function isValidKeysJson(text) {
var trimmed = (text || "").trim()
if (trimmed.length === 0) return false
var obj
try {
obj = JSON.parse(trimmed)
} catch (e) {
return false
}
return !!obj && typeof obj === "object" && !Array.isArray(obj)
&& typeof obj.nullifier_public_key === "string" && obj.nullifier_public_key.length > 0
&& typeof obj.viewing_public_key === "string" && obj.viewing_public_key.length > 0
}
}
ColumnLayout {
anchors.fill: parent
spacing: Theme.spacing.large
// Transfer type toggle
TabBar {
id: transferTypeBar
Layout.fillWidth: true
spacing: Theme.spacing.small
currentIndex: 0
background: Rectangle {
color: Theme.palette.backgroundSecondary
radius: Theme.spacing.radiusSmall
}
LogosTabButton {
text: qsTr("Public")
}
LogosTabButton {
text: qsTr("Private")
}
LogosTabButton {
text: qsTr("Shielded")
}
LogosTabButton {
text: qsTr("Deshielded")
}
}
// From: dropdown when accounts exist, or manual entry when list is empty
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("From")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
LogosTextField {
id: manualFromField
Layout.fillWidth: true
placeholderText: qsTr("Paste or type from account ID")
visible: root.fromFilterCount === 0
}
AccountComboBox {
id: fromCombo
Layout.fillWidth: true
model: (d.isPrivateTab || d.isDeshieldedTab) ? root.privateAccountModel : root.publicAccountModel
visible: root.fromFilterCount > 0
onCopyRequested: (text) => root.copyRequested(text)
}
}
// To field
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("To")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
LogosCheckbox {
id: useOwnedToCheck
checked: d.useOwnedAccountForTo
onCheckedChanged: d.useOwnedAccountForTo = checked
text: qsTr("Use owned account")
}
LogosTextField {
id: toField
Layout.fillWidth: true
placeholderText: (d.isPublicTab || d.isDeshieldedTab) ? qsTr("Recipient account ID") : qsTr("Recipient public keys (JSON)")
visible: !d.useOwnedAccountForTo
}
LogosText {
Layout.fillWidth: true
visible: d.needsKeysJson && toField.text.trim().length > 0 && !d.toAddressValid
text: qsTr("Enter the recipient's public keys as JSON with nullifier_public_key and viewing_public_key.")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.error
wrapMode: Text.WordWrap
}
AccountComboBox {
id: toCombo
Layout.fillWidth: true
model: (d.isPublicTab || d.isDeshieldedTab) ? root.publicAccountModel : root.privateAccountModel
visible: d.useOwnedAccountForTo && root.toFilterCount > 0
onCopyRequested: (text) => root.copyRequested(text)
}
}
// Amount field
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("Amount")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
LogosTextField {
id: amountField
Layout.fillWidth: true
placeholderText: "0.00"
}
}
// Send button
LogosButton {
Layout.fillWidth: true
text: qsTr("Send")
font.pixelSize: Theme.typography.secondaryText
enabled: d.sendEnabled
onClicked: {
var fromId = root.fromFilterCount > 0 && fromCombo.currentIndex >= 0
? (fromCombo.currentValue ?? "")
: Base58.decode(manualFromField.text.trim())
var rawTo = toField.text.trim()
var toAddress = (d.useOwnedAccountForTo && toCombo.currentIndex >= 0)
? (toCombo.currentValue ?? "")
: (d.needsKeysJson ? rawTo : Base58.decode(rawTo))
var amount = amountField.text.trim()
if (fromId.length > 0 && toAddress.length > 0 && amount.length > 0) {
if (d.isPublicTab)
root.transferPublicRequested(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)
}
}
}
}
Item {
Layout.fillHeight: true
}
}
}

View File

@ -0,0 +1,122 @@
import QtQuick
import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
import "../controls"
import "../Base58.js" as Base58
Item {
id: root
// --- Public API: data in ---
property var publicAccountModel: null
property bool transferPending: false
// --- Public API: signals out (match backend: bridge_withdraw) ---
signal bridgeWithdrawRequested(string fromAccountId, string bedrockAccountPkHex, string amount)
signal copyRequested(string copyText)
readonly property int fromFilterCount: fromCombo.count
QtObject {
id: d
readonly property bool sendEnabled: !root.transferPending
&& amountField && manualFromField && toField
&& amountField.text.length > 0
&& toField.text.trim().length > 0
&& ((root.fromFilterCount > 0 && fromCombo.currentIndex >= 0)
|| (root.fromFilterCount === 0 && manualFromField.text.trim().length > 0))
}
ColumnLayout {
anchors.fill: parent
spacing: Theme.spacing.large
// From: dropdown when public accounts exist, or manual entry when list is empty.
// Bridge withdrawals only support public sender accounts.
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("From")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
LogosTextField {
id: manualFromField
Layout.fillWidth: true
placeholderText: qsTr("Paste or type from account ID")
visible: root.fromFilterCount === 0
}
AccountComboBox {
id: fromCombo
Layout.fillWidth: true
model: root.publicAccountModel
visible: root.fromFilterCount > 0
onCopyRequested: (text) => root.copyRequested(text)
}
}
// Bedrock (L1) recipient public key
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("Bedrock (L1) public key")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
LogosTextField {
id: toField
Layout.fillWidth: true
placeholderText: qsTr("Recipient's Bedrock public key (hex)")
}
}
// Amount field
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("Amount")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
LogosTextField {
id: amountField
Layout.fillWidth: true
placeholderText: "0.00"
}
}
// Withdraw button
LogosButton {
Layout.fillWidth: true
text: qsTr("Withdraw")
font.pixelSize: Theme.typography.secondaryText
enabled: d.sendEnabled
onClicked: {
var fromId = root.fromFilterCount > 0 && fromCombo.currentIndex >= 0
? (fromCombo.currentValue ?? "")
: Base58.decode(manualFromField.text.trim())
var toAddress = toField.text.trim()
var amount = amountField.text.trim()
if (fromId.length > 0 && toAddress.length > 0 && amount.length > 0)
root.bridgeWithdrawRequested(fromId, toAddress, amount)
}
}
Item {
Layout.fillHeight: true
}
}
}