diff --git a/metadata.json b/metadata.json index b54865e..faf3d59 100644 --- a/metadata.json +++ b/metadata.json @@ -1,5 +1,6 @@ { "name": "blockchain_ui", + "display_name": "Blockchain", "version": "1.0.0", "type": "ui_qml", "category": "blockchain", diff --git a/src/BlockchainBackend.cpp b/src/BlockchainBackend.cpp index 758f31d..199d095 100644 --- a/src/BlockchainBackend.cpp +++ b/src/BlockchainBackend.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -16,6 +17,8 @@ #include #include +#include + const QString BlockchainBackend::BLOCKCHAIN_MODULE_NAME = QStringLiteral("liblogos_blockchain_module"); @@ -74,6 +77,44 @@ static QVariantMap toVariantMap(const LogosResult& result) } // namespace result +// Decode a base58 (Bitcoin alphabet) string to raw bytes. On an invalid +// character *ok is set to false and an empty array is returned. +static QByteArray decodeBase58(const QString& input, bool* ok) +{ + static const QByteArray kAlphabet = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + + const QByteArray s = input.trimmed().toLatin1(); + QByteArray bytes; // little-endian while building, reversed at the end + bytes.append('\0'); + + for (const char c : s) { + const int value = kAlphabet.indexOf(c); + if (value < 0) { + if (ok) *ok = false; + return {}; + } + int carry = value; + for (int j = 0; j < bytes.size(); ++j) { + carry += static_cast(bytes[j]) * 58; + bytes[j] = static_cast(carry & 0xff); + carry >>= 8; + } + while (carry > 0) { + bytes.append(static_cast(carry & 0xff)); + carry >>= 8; + } + } + + // Each leading '1' maps to a leading zero byte. + for (int i = 0; i < s.size() && s[i] == '1'; ++i) + bytes.append('\0'); + + std::reverse(bytes.begin(), bytes.end()); + if (ok) *ok = true; + return bytes; +} + BlockchainBackend::BlockchainBackend(LogosAPI* logosAPI, QObject* parent) : BlockchainBackendSimpleSource(parent) , m_logosAPI(logosAPI) @@ -223,9 +264,28 @@ void BlockchainBackend::refreshAccounts() const LogosResult r = result::toLogosResult(m_blockchainClient->invokeRemoteMethod( BLOCKCHAIN_MODULE_NAME, "wallet_get_known_addresses")); + if (!r.success) { + qWarning() << "refreshAccounts: failed:" << r.error.toString(); + return; + } + + // The SDK marshals the JSON array into a QVariantList; rely on toList() + // rather than canConvert() (which is unreliable for a + // QVariantList under Qt6), and fall back to toStringList() for the rare + // case where the value already arrives as a QStringList. QStringList list; - if (r.success && r.value.canConvert()) + const QVariantList items = r.value.toList(); + if (!items.isEmpty()) { + for (const QVariant& item : items) { + const QString addr = item.toString(); + if (!addr.isEmpty()) + list << addr; + } + } else { list = r.value.toStringList(); + } + + qDebug() << "refreshAccounts: loaded" << list.size() << "addresses"; m_accountsModel->setAddresses(list); @@ -323,6 +383,46 @@ QVariantMap BlockchainBackend::generateConfig( BLOCKCHAIN_MODULE_NAME, "generate_user_config", jsonToSend))); } +QVariantMap BlockchainBackend::getNotes(QString walletAddressHex, QString optionalTipHex) +{ + if (!m_blockchainClient) + return result::toVariantMap(result::err(QStringLiteral("Module not initialized."))); + + return result::toVariantMap(result::toLogosResult(m_blockchainClient->invokeRemoteMethod( + BLOCKCHAIN_MODULE_NAME, "wallet_get_notes", + walletAddressHex, optionalTipHex))); +} + +QVariantMap BlockchainBackend::channelDepositWithNotes( + QString channelIdHex, QStringList inputNoteIdHexes, QString metadataBase58, + QString changePublicKeyHex, QStringList fundingPublicKeyHexes, + QString maxTxFee, QString optionalTipHex) +{ + if (!m_blockchainClient) + return result::toVariantMap(result::err(QStringLiteral("Module not initialized."))); + + // The metadata arrives base58-encoded; the module expects metadata_hex, so + // decode to bytes and hex-encode. Empty stays empty (metadata is optional). + QString metadataHex; + if (!metadataBase58.trimmed().isEmpty()) { + bool ok = false; + const QByteArray bytes = decodeBase58(metadataBase58, &ok); + if (!ok) + return result::toVariantMap(result::err(QStringLiteral("Invalid base58 metadata."))); + metadataHex = QString::fromLatin1(bytes.toHex()); + } + + // 7 positional args exceed the variadic invokeRemoteMethod overloads + // (max 5), so pass them through the QVariantList form. + QVariantList args; + args << channelIdHex << inputNoteIdHexes << metadataHex << changePublicKeyHex + << fundingPublicKeyHexes << maxTxFee << optionalTipHex; + + return result::toVariantMap(result::toLogosResult(m_blockchainClient->invokeRemoteMethod( + BLOCKCHAIN_MODULE_NAME, QStringLiteral("channel_deposit_with_notes"), + args))); +} + void BlockchainBackend::clearLogs() { m_logModel->clear(); @@ -330,6 +430,14 @@ void BlockchainBackend::clearLogs() void BlockchainBackend::copyToClipboard(QString text) { - if (QGuiApplication::clipboard()) - QGuiApplication::clipboard()->setText(text); + // The backend runs in a non-GUI ViewModuleHost subprocess, where there is + // no QGuiApplication and accessing the clipboard segfaults. Clipboard is + // handled QML-side (see BlockchainView.copyText); guard here so any stray + // call is a no-op rather than a crash. + if (!qobject_cast(QCoreApplication::instance())) { + qWarning() << "copyToClipboard: no GUI application; ignoring"; + return; + } + if (QClipboard* clipboard = QGuiApplication::clipboard()) + clipboard->setText(text); } diff --git a/src/BlockchainBackend.h b/src/BlockchainBackend.h index b628f57..4c5a342 100644 --- a/src/BlockchainBackend.h +++ b/src/BlockchainBackend.h @@ -49,6 +49,14 @@ public slots: int blendPort, QString httpAddr, QString externalAddress, bool noPublicIpCheck, int deploymentMode, QString deploymentConfigPath, QString statePath) override; + QVariantMap getNotes(QString walletAddressHex, QString optionalTipHex) override; + QVariantMap channelDepositWithNotes(QString channelIdHex, + QStringList inputNoteIdHexes, + QString metadataBase58, + QString changePublicKeyHex, + QStringList fundingPublicKeyHexes, + QString maxTxFee, + QString optionalTipHex) override; void clearLogs() override; void copyToClipboard(QString text) override; diff --git a/src/BlockchainBackend.rep b/src/BlockchainBackend.rep index c657fbc..ca27996 100644 --- a/src/BlockchainBackend.rep +++ b/src/BlockchainBackend.rep @@ -16,6 +16,8 @@ class BlockchainBackend SLOT(QVariantMap transferFunds(QString fromKeyHex, QString toKeyHex, QString amountStr)) SLOT(QVariantMap claimLeaderRewards()) SLOT(QVariantMap generateConfig(QString outputPath, QStringList initialPeers, int netPort, int blendPort, QString httpAddr, QString externalAddress, bool noPublicIpCheck, int deploymentMode, QString deploymentConfigPath, QString statePath)) + SLOT(QVariantMap getNotes(QString walletAddressHex, QString optionalTipHex)) + SLOT(QVariantMap channelDepositWithNotes(QString channelIdHex, QStringList inputNoteIdHexes, QString metadataBase58, QString changePublicKeyHex, QStringList fundingPublicKeyHexes, QString maxTxFee, QString optionalTipHex)) SLOT(void clearLogs()) SLOT(void copyToClipboard(QString text)) } diff --git a/src/qml/BlockchainView.qml b/src/qml/BlockchainView.qml index 6e41136..21a2efa 100644 --- a/src/qml/BlockchainView.qml +++ b/src/qml/BlockchainView.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import QtQuick.Layouts import Logos.Theme +import Logos.Controls // BlockchainStatus enum (NotStarted/Starting/Running/Stopping/Stopped/Error) // declared in BlockchainBackend.rep — registered with QML by the replica // factory plugin. @@ -39,6 +40,23 @@ Rectangle { readonly property var accountsModel: logos.model("blockchain_ui", "accounts") readonly property var logModel: logos.model("blockchain_ui", "logs") + // Clipboard must be handled here in the UI-host (GUI) process. The backend + // .rep source runs in a separate, non-GUI ViewModuleHost subprocess where + // QGuiApplication::clipboard() segfaults (process exits with code 11), so + // we copy from QML via a hidden TextEdit instead of calling the backend. + function copyText(text) { + clipboardHelper.text = text || "" + clipboardHelper.selectAll() + clipboardHelper.copy() + clipboardHelper.deselect() + clipboardHelper.text = "" + } + + TextEdit { + id: clipboardHelper + visible: false + } + QtObject { id: _d function errorText(message) { @@ -152,7 +170,27 @@ Rectangle { } } - // Page 2: Node control, wallet, logs + // Page 2: Node control, wallet, logs + Channel Deposit (tabbed) + ColumnLayout { + spacing: Theme.spacing.medium + + LogosTabBar { + id: operationTabBar + Layout.fillWidth: true + LogosTabButton { text: qsTr("Node & Wallet") } + LogosTabButton { + text: qsTr("Channel Deposit") + enabled: root.backend + && root.backend.status === BlockchainBackend.Running + } + } + + StackLayout { + id: operationStack + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: operationTabBar.currentIndex + SplitView { orientation: Qt.Vertical @@ -209,7 +247,7 @@ Rectangle { ) } onCopyToClipboard: (text) => { - if (root.backend) root.backend.copyToClipboard(text) + root.copyText(text) } onTransferRequested: function(fromKeyHex, toKeyHex, amount) { if (!root.backend) return @@ -239,6 +277,7 @@ Rectangle { function(error) { walletView.setLeaderClaimResult(_d.errorText(error)) } ) } + onRefreshAccountsRequested: if (root.backend) root.backend.refreshAccounts() } Item { @@ -253,7 +292,49 @@ Rectangle { logModel: root.logModel onClearRequested: if (root.backend) root.backend.clearLogs() onCopyToClipboard: (text) => { - if (root.backend) root.backend.copyToClipboard(text) + root.copyText(text) + } + } + } + + ChannelDepositView { + id: channelDepositView + accountsModel: root.accountsModel + nodeRunning: root.backend + ? root.backend.status === BlockchainBackend.Running + : false + + onGetNotesRequested: function(addressHex, optionalTipHex) { + if (!root.backend) return + logos.watch( + root.backend.getNotes(addressHex, optionalTipHex), + function(result) { + if (result.success) + channelDepositView.setNotes(result.value) + else + channelDepositView.setNotesError(_d.errorText(result.error)) + }, + function(error) { channelDepositView.setNotesError(_d.errorText(error)) } + ) + } + onSubmitRequested: function(channelIdHex, inputNoteIdHexes, metadataBase58, changePublicKeyHex, fundingPublicKeyHexes, maxTxFee, optionalTipHex) { + if (!root.backend) return + logos.watch( + root.backend.channelDepositWithNotes( + channelIdHex, inputNoteIdHexes, metadataBase58, + changePublicKeyHex, fundingPublicKeyHexes, maxTxFee, optionalTipHex), + function(result) { + if (result.success) + channelDepositView.setSubmitResult(true, result.value) + else + channelDepositView.setSubmitResult(false, _d.errorText(result.error)) + }, + function(error) { channelDepositView.setSubmitResult(false, _d.errorText(error)) } + ) + } + onCopyToClipboard: (text) => { + root.copyText(text) + } } } } diff --git a/src/qml/controls/NoteSelector.qml b/src/qml/controls/NoteSelector.qml new file mode 100644 index 0000000..d7a5af8 --- /dev/null +++ b/src/qml/controls/NoteSelector.qml @@ -0,0 +1,162 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +// Widget for selecting spendable notes (UTXOs) to consume in a channel deposit. +// +// The owner sets `notes` (a JS array of { id, value } parsed from +// wallet_get_notes) and reads back the selection via selectedIds() and the +// selectedCount / selectedTotal properties. +ColumnLayout { + id: root + + // Array of { id: "", value: "" }. Assign to (re)populate. + property var notes: [] + property bool loading: false + property string errorText: "" + + // Observable selection summary — bindings can't track ListModel reads, so + // these properties are recomputed explicitly on every selection change. + property int selectedCount: 0 + property double selectedTotal: 0 + + function selectedIds() { + var ids = [] + for (var i = 0; i < notesModel.count; ++i) { + var n = notesModel.get(i) + if (n.selected) + ids.push(n.noteId) + } + return ids + } + + function recompute() { + var c = 0 + var total = 0 + for (var i = 0; i < notesModel.count; ++i) { + var n = notesModel.get(i) + if (n.selected) { + c++ + total += Number(n.value) + } + } + root.selectedCount = c + root.selectedTotal = total + } + + function clearSelection() { + for (var i = 0; i < notesModel.count; ++i) + notesModel.setProperty(i, "selected", false) + recompute() + } + + onNotesChanged: { + notesModel.clear() + var arr = root.notes || [] + for (var i = 0; i < arr.length; ++i) { + notesModel.append({ + noteId: String(arr[i].id), + value: String(arr[i].value), + selected: false + }) + } + recompute() + } + + spacing: Theme.spacing.small + + ListModel { id: notesModel } + + LogosText { + Layout.fillWidth: true + visible: root.loading + text: qsTr("Loading notes…") + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + + LogosText { + Layout.fillWidth: true + visible: !root.loading && root.errorText !== "" + text: root.errorText + color: Theme.palette.error + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WordWrap + } + + LogosText { + Layout.fillWidth: true + visible: !root.loading && root.errorText === "" && notesModel.count === 0 + text: qsTr("No notes found for this address. Load notes for a funded address.") + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WordWrap + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 220 + visible: notesModel.count > 0 + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + ListView { + id: notesList + anchors.fill: parent + anchors.margins: Theme.spacing.small + clip: true + model: notesModel + spacing: Theme.spacing.tiny + + delegate: RowLayout { + width: notesList.width + spacing: Theme.spacing.small + + LogosCheckbox { + checked: model.selected + onClicked: { + notesModel.setProperty(index, "selected", checked) + root.recompute() + } + } + + LogosText { + Layout.fillWidth: true + text: model.noteId + elide: Text.ElideMiddle + font.pixelSize: Theme.typography.secondaryText + } + + LogosText { + Layout.alignment: Qt.AlignRight + text: model.value + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + } + } + } + + RowLayout { + Layout.fillWidth: true + visible: notesModel.count > 0 + spacing: Theme.spacing.small + + LogosText { + text: qsTr("%1 selected").arg(root.selectedCount) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + Item { Layout.fillWidth: true } + LogosText { + text: qsTr("Total: %1").arg(root.selectedTotal) + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + } +} diff --git a/src/qml/views/ChannelDepositView.qml b/src/qml/views/ChannelDepositView.qml new file mode 100644 index 0000000..3e91281 --- /dev/null +++ b/src/qml/views/ChannelDepositView.qml @@ -0,0 +1,542 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +import "../controls" + +// Multi-step wizard for channel_deposit_with_notes: +// 1. Select notes → wallet_get_notes, pick UTXOs to consume +// 2. Fill fields → channel id, change/funding keys, fee, metadata, tip +// 3. Confirm → review the exact payload +// 4. Result → tx hash (copyable) or error +ColumnLayout { + id: root + + // Known wallet addresses (auto-remoted accounts model). Also used as + // public keys for the change/funding key pickers. + property var accountsModel: null + property bool nodeRunning: false + + signal getNotesRequested(string addressHex, string optionalTipHex) + signal submitRequested(string channelIdHex, var inputNoteIdHexes, string metadataBase58, string changePublicKeyHex, var fundingPublicKeyHexes, string maxTxFee, string optionalTipHex) + signal copyToClipboard(string text) + + // --- Called by the parent after async backend calls return --- + + function setNotesLoading() { + noteSelector.loading = true + noteSelector.errorText = "" + } + + function setNotes(jsonStr) { + noteSelector.loading = false + var s = jsonStr || "" + try { + var parsed = JSON.parse(s) + d.notesTip = parsed.tip || "" + noteSelector.errorText = "" + noteSelector.notes = parsed.notes || [] + } catch (e) { + noteSelector.errorText = qsTr("Failed to parse notes: %1").arg(s) + noteSelector.notes = [] + } + } + + function setNotesError(message) { + noteSelector.loading = false + noteSelector.errorText = message + noteSelector.notes = [] + } + + function setSubmitResult(success, text) { + d.resultPending = false + d.resultSuccess = success + d.resultText = text + } + + spacing: Theme.spacing.large + + QtObject { + id: d + + property int step: 0 + readonly property int stepCount: 4 + property string notesTip: "" + + // result state + property bool resultPending: false + property bool resultSuccess: false + property string resultText: "" + + function fundingKeyList() { + return fundingKeysArea.text.split("\n") + .map(function(s) { return s.trim() }) + .filter(function(s) { return s.length > 0 }) + } + + // --- Metadata (base58-encoded bytes) --- + + // The actual base58 → bytes decoding happens in the C++ backend; here we + // only check the input uses the base58 (Bitcoin) alphabet. Plain base58 + // has no checksum, so a valid-alphabet string always decodes. + function metadataIsValid() { + var s = metadataField.text.trim() + if (s === "") + return true // optional + return /^[1-9A-HJ-NP-Za-km-z]+$/.test(s) + } + + function canAdvance() { + switch (step) { + case 0: + return noteSelector.selectedCount > 0 + case 1: + return channelIdField.text.trim().length > 0 + && changeKeyField.text.trim().length > 0 + && fundingKeyList().length > 0 + && maxFeeField.text.trim().length > 0 + && metadataIsValid() + default: + return true + } + } + + function goNext() { + if (step === 1) { + // entering confirm — nothing else + } + if (step < stepCount - 1) + step++ + // Prefill key fields from the selected wallet when first reaching step 1. + if (step === 1) { + if (changeKeyField.text.trim() === "") + changeKeyField.text = walletField.text.trim() + if (fundingKeysArea.text.trim() === "" && walletField.text.trim() !== "") + fundingKeysArea.text = walletField.text.trim() + } + } + + function goBack() { + if (step > 0) + step-- + } + + function submit() { + d.resultPending = true + d.resultSuccess = false + d.resultText = "" + step = 3 + root.submitRequested( + channelIdField.text.trim(), + noteSelector.selectedIds(), + metadataField.text.trim(), + changeKeyField.text.trim(), + fundingKeyList(), + maxFeeField.text.trim(), + tipField.text.trim()) + } + + function reset() { + noteSelector.clearSelection() + noteSelector.notes = [] + noteSelector.errorText = "" + channelIdField.text = "" + changeKeyField.text = "" + fundingKeysArea.text = "" + maxFeeField.text = "" + metadataField.text = "" + tipField.text = "" + notesTip = "" + resultText = "" + resultPending = false + step = 0 + } + + function summaryLines() { + var ids = noteSelector.selectedIds() + return [ + { k: qsTr("Channel ID"), v: channelIdField.text.trim() }, + { k: qsTr("Notes to consume (%1)").arg(ids.length), v: ids.join("\n") }, + { k: qsTr("Total amount"), v: String(noteSelector.selectedTotal) }, + { k: qsTr("Change public key"), v: changeKeyField.text.trim() }, + { k: qsTr("Funding public keys"), v: fundingKeyList().join("\n") }, + { k: qsTr("Max tx fee"), v: maxFeeField.text.trim() }, + { k: qsTr("Metadata (base58)"), v: metadataField.text.trim() || qsTr("(none)") }, + { k: qsTr("Optional tip hex"), v: tipField.text.trim() || qsTr("(current tip)") } + ] + } + } + + // ---- Header / step indicator ---- + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.medium + + LogosText { + text: qsTr("Channel Deposit") + font.pixelSize: Theme.typography.primaryText + font.bold: true + } + Item { Layout.fillWidth: true } + LogosText { + text: qsTr("Step %1 of %2").arg(d.step + 1).arg(d.stepCount) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + } + + LogosText { + Layout.fillWidth: true + visible: !root.nodeRunning + text: qsTr("Start the node before making a deposit.") + color: Theme.palette.warning + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WordWrap + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: d.step + + // ---- Step 0: Select notes ---- + ColumnLayout { + spacing: Theme.spacing.medium + + LogosText { + Layout.fillWidth: true + text: qsTr("Select the notes (UTXOs) to deposit into the channel. Their full value is consumed.") + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + LogosComboBox { + Layout.preferredWidth: 200 + placeholderText: qsTr("Known address…") + model: root.accountsModel + textRole: "address" + currentIndex: -1 + onActivated: function(index) { walletField.text = currentText } + } + LogosTextField { + id: walletField + Layout.fillWidth: true + placeholderText: qsTr("Wallet address hex") + } + LogosButton { + text: qsTr("Load notes") + enabled: root.nodeRunning && walletField.text.trim().length > 0 + onClicked: { + root.setNotesLoading() + root.getNotesRequested(walletField.text.trim(), "") + } + } + } + + NoteSelector { + id: noteSelector + Layout.fillWidth: true + } + + Item { Layout.fillHeight: true } + } + + // ---- Step 1: Fields ---- + ScrollView { + id: fieldsScroll + clip: true + ColumnLayout { + width: fieldsScroll.availableWidth + spacing: Theme.spacing.medium + + LogosText { + text: qsTr("Channel ID hex") + font.pixelSize: Theme.typography.secondaryText + } + LogosTextField { + id: channelIdField + Layout.fillWidth: true + placeholderText: qsTr("Channel ID hex") + } + + LogosText { + text: qsTr("Change public key (receives change)") + font.pixelSize: Theme.typography.secondaryText + } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + LogosComboBox { + Layout.preferredWidth: 200 + placeholderText: qsTr("Known address…") + model: root.accountsModel + textRole: "address" + currentIndex: -1 + onActivated: function(index) { changeKeyField.text = currentText } + } + LogosTextField { + id: changeKeyField + Layout.fillWidth: true + placeholderText: qsTr("Change public key hex") + } + } + + LogosText { + text: qsTr("Funding public keys (one per line, fund the gas fee)") + font.pixelSize: Theme.typography.secondaryText + } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + Item { Layout.fillWidth: true } + LogosComboBox { + Layout.preferredWidth: 220 + placeholderText: qsTr("Add known address…") + model: root.accountsModel + textRole: "address" + currentIndex: -1 + onActivated: function(index) { + var t = fundingKeysArea.text.trim() + fundingKeysArea.text = (t.length > 0 ? t + "\n" : "") + currentText + } + } + } + ScrollView { + Layout.fillWidth: true + Layout.preferredHeight: 80 + clip: true + TextArea { + id: fundingKeysArea + background: Rectangle { + radius: Theme.spacing.radiusSmall + color: Theme.palette.backgroundSecondary + border.width: 1 + border.color: Theme.palette.backgroundElevated + } + placeholderText: qsTr("Funding public key hex, one per line") + placeholderTextColor: Theme.palette.textTertiary + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.text + } + } + + LogosText { + text: qsTr("Max tx fee") + font.pixelSize: Theme.typography.secondaryText + } + LogosTextField { + id: maxFeeField + Layout.fillWidth: true + placeholderText: qsTr("Maximum transaction fee") + } + + LogosText { + text: qsTr("Metadata (base58, optional)") + font.pixelSize: Theme.typography.secondaryText + } + LogosTextField { + id: metadataField + Layout.fillWidth: true + placeholderText: qsTr("Base58-encoded metadata bytes") + } + LogosText { + Layout.fillWidth: true + text: qsTr("Input must be base58-encoded; it is decoded to bytes before submission.") + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WordWrap + } + LogosText { + Layout.fillWidth: true + visible: metadataField.text.trim() !== "" && !d.metadataIsValid() + text: qsTr("Invalid base58 input") + color: Theme.palette.error + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WordWrap + } + + LogosText { + text: qsTr("Optional tip hex (leave empty for current tip)") + font.pixelSize: Theme.typography.secondaryText + } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + LogosTextField { + id: tipField + Layout.fillWidth: true + placeholderText: qsTr("Tip hex") + } + LogosButton { + text: qsTr("Use query tip") + enabled: d.notesTip !== "" + onClicked: tipField.text = d.notesTip + } + } + } + } + + // ---- Step 2: Confirm ---- + ColumnLayout { + spacing: Theme.spacing.medium + + LogosText { + Layout.fillWidth: true + text: qsTr("Review the deposit. This is the exact payload that will be submitted.") + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WordWrap + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + ScrollView { + id: confirmScroll + anchors.fill: parent + anchors.margins: Theme.spacing.large + clip: true + ColumnLayout { + width: confirmScroll.availableWidth + spacing: Theme.spacing.medium + Repeater { + // Re-evaluated when the confirm step is shown so it + // reflects the latest field values. + model: d.step === 2 ? d.summaryLines() : [] + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacing.tiny + LogosText { + text: modelData.k + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + LogosText { + Layout.fillWidth: true + text: modelData.v + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WrapAnywhere + } + } + } + } + } + } + } + + // ---- Step 3: Result ---- + ColumnLayout { + spacing: Theme.spacing.medium + + LogosText { + Layout.alignment: Qt.AlignHCenter + visible: d.resultPending + text: qsTr("Submitting deposit…") + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + BusyIndicator { + Layout.alignment: Qt.AlignHCenter + visible: d.resultPending + running: d.resultPending + } + + LogosText { + Layout.fillWidth: true + visible: !d.resultPending + text: d.resultSuccess ? qsTr("Deposit submitted") : qsTr("Deposit failed") + color: d.resultSuccess ? Theme.palette.success : Theme.palette.error + font.pixelSize: Theme.typography.primaryText + font.bold: true + } + + // Success: tx hash + copy + RowLayout { + Layout.fillWidth: true + visible: !d.resultPending && d.resultSuccess + spacing: Theme.spacing.small + Rectangle { + Layout.fillWidth: true + implicitHeight: txHashText.implicitHeight + Theme.spacing.medium + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusSmall + border.color: Theme.palette.border + border.width: 1 + LogosText { + id: txHashText + anchors.fill: parent + anchors.margins: Theme.spacing.small + text: d.resultText + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WrapAnywhere + verticalAlignment: Text.AlignVCenter + } + } + LogosCopyButton { + Layout.preferredHeight: 40 + Layout.preferredWidth: 40 + onCopyText: root.copyToClipboard(d.resultText) + } + } + + // Error + LogosText { + Layout.fillWidth: true + visible: !d.resultPending && !d.resultSuccess && d.resultText !== "" + text: d.resultText + color: Theme.palette.error + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WordWrap + } + + Item { Layout.fillHeight: true } + } + } + + // ---- Footer navigation ---- + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + LogosButton { + text: qsTr("Back") + visible: d.step > 0 && d.step < 3 + onClicked: d.goBack() + } + Item { Layout.fillWidth: true } + + // Steps 0 & 1: Next + LogosButton { + text: qsTr("Next") + visible: d.step < 2 + enabled: d.canAdvance() + onClicked: d.goNext() + } + // Step 2: Confirm & submit + LogosButton { + text: qsTr("Confirm & deposit") + visible: d.step === 2 + enabled: root.nodeRunning + onClicked: d.submit() + } + // Step 3: New deposit (after completion) + LogosButton { + text: qsTr("New deposit") + visible: d.step === 3 && !d.resultPending + onClicked: d.reset() + } + } +} diff --git a/src/qml/views/LogsView.qml b/src/qml/views/LogsView.qml index 3ed4d18..f0a4372 100644 --- a/src/qml/views/LogsView.qml +++ b/src/qml/views/LogsView.qml @@ -70,7 +70,9 @@ Control { delegate: ItemDelegate{ width: ListView.view.width contentItem: LogosText { - text: model.text + // The remoted log model can briefly report an undefined + // "text" role while the replica syncs — coerce to "". + text: model.text || "" font.pixelSize: Theme.typography.secondaryText wrapMode: Text.Wrap } @@ -78,7 +80,7 @@ Control { color: hovered ? Theme.palette.background: "transparent" radius: 2 } - onClicked: root.copyToClipboard(model.text) + onClicked: root.copyToClipboard(model.text || "") } LogosText { diff --git a/src/qml/views/WalletView.qml b/src/qml/views/WalletView.qml index 8fe2164..345ea9e 100644 --- a/src/qml/views/WalletView.qml +++ b/src/qml/views/WalletView.qml @@ -16,6 +16,7 @@ RowLayout { property string lastBalanceErrorAddress: "" signal getBalanceRequested(string addressHex) + signal refreshAccountsRequested() signal transferRequested(string fromKeyHex, string toKeyHex, string amount) signal claimLeaderRewardsRequested() signal copyToClipboard(string text) @@ -48,10 +49,19 @@ RowLayout { anchors.margins: Theme.spacing.large spacing: Theme.spacing.large - LogosText { - text: qsTr("Accounts") - font.pixelSize: Theme.typography.secondaryText - font.bold: true + RowLayout { + Layout.fillWidth: true + LogosText { + text: qsTr("Accounts") + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + Item { Layout.fillWidth: true } + LogosButton { + text: qsTr("Refresh") + padding: Theme.spacing.small + onClicked: root.refreshAccountsRequested() + } } LogosText { diff --git a/src/qml/views/qmldir b/src/qml/views/qmldir index a90a4e1..fbca773 100644 --- a/src/qml/views/qmldir +++ b/src/qml/views/qmldir @@ -4,4 +4,5 @@ LogsView 1.0 LogsView.qml WalletView 1.0 WalletView.qml GenerateConfigView 1.0 GenerateConfigView.qml ConfigChoiceView 1.0 ConfigChoiceView.qml -SetConfigPathView 1.0 SetConfigPathView.qml \ No newline at end of file +SetConfigPathView 1.0 SetConfigPathView.qml +ChannelDepositView 1.0 ChannelDepositView.qml \ No newline at end of file