Implement deposit ui

This commit is contained in:
Daniel 2026-06-19 13:35:13 +02:00
parent 2caa5baa1b
commit 098a3c8dd5
7 changed files with 775 additions and 2 deletions

View File

@ -277,6 +277,39 @@ int BlockchainBackend::generateConfig(
return result.isValid() ? result.toInt() : -1;
}
QString BlockchainBackend::getNotes(QString walletAddressHex, QString optionalTipHex)
{
if (!m_blockchainClient)
return QStringLiteral("Error: Module not initialized.");
QVariant result = m_blockchainClient->invokeRemoteMethod(
BLOCKCHAIN_MODULE_NAME, "wallet_get_notes",
walletAddressHex, optionalTipHex);
return result.isValid() ? result.toString()
: QStringLiteral("Error: Call failed.");
}
QString BlockchainBackend::channelDepositWithNotes(
QString channelIdHex, QStringList inputNoteIdHexes, QString metadataHex,
QString changePublicKeyHex, QStringList fundingPublicKeyHexes,
QString maxTxFee, QString optionalTipHex)
{
if (!m_blockchainClient)
return QStringLiteral("Error: Module not initialized.");
// 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;
QVariant result = m_blockchainClient->invokeRemoteMethod(
BLOCKCHAIN_MODULE_NAME, QStringLiteral("channel_deposit_with_notes"),
args);
return result.isValid() ? result.toString()
: QStringLiteral("Error: Call failed.");
}
void BlockchainBackend::clearLogs()
{
m_logModel->clear();

View File

@ -47,6 +47,14 @@ public slots:
int blendPort, QString httpAddr, QString externalAddress,
bool noPublicIpCheck, int deploymentMode,
QString deploymentConfigPath, QString statePath) override;
QString getNotes(QString walletAddressHex, QString optionalTipHex) override;
QString channelDepositWithNotes(QString channelIdHex,
QStringList inputNoteIdHexes,
QString metadataHex,
QString changePublicKeyHex,
QStringList fundingPublicKeyHexes,
QString maxTxFee,
QString optionalTipHex) override;
void clearLogs() override;
void copyToClipboard(QString text) override;

View File

@ -14,6 +14,8 @@ class BlockchainBackend
SLOT(QString getBalance(QString addressHex))
SLOT(QString transferFunds(QString fromKeyHex, QString toKeyHex, QString amountStr))
SLOT(int generateConfig(QString outputPath, QStringList initialPeers, int netPort, int blendPort, QString httpAddr, QString externalAddress, bool noPublicIpCheck, int deploymentMode, QString deploymentConfigPath, QString statePath))
SLOT(QString getNotes(QString walletAddressHex, QString optionalTipHex))
SLOT(QString channelDepositWithNotes(QString channelIdHex, QStringList inputNoteIdHexes, QString metadataHex, QString changePublicKeyHex, QStringList fundingPublicKeyHexes, QString maxTxFee, QString optionalTipHex))
SLOT(void clearLogs())
SLOT(void copyToClipboard(QString text))
}

View File

@ -3,6 +3,7 @@ import QtQuick.Controls
import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
// BlockchainStatus enum (NotStarted/Starting/Running/.../ErrorSubscribeFailed)
// declared in BlockchainBackend.rep registered with QML by the replica
// factory plugin.
@ -155,7 +156,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
@ -239,6 +260,38 @@ Rectangle {
if (root.backend) root.backend.copyToClipboard(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) { channelDepositView.setNotesResult(result) },
function(error) { channelDepositView.setNotesResult("Error: " + error) }
)
}
onSubmitRequested: function(channelIdHex, inputNoteIdHexes, metadataHex, changePublicKeyHex, fundingPublicKeyHexes, maxTxFee, optionalTipHex) {
if (!root.backend) return
logos.watch(
root.backend.channelDepositWithNotes(
channelIdHex, inputNoteIdHexes, metadataHex,
changePublicKeyHex, fundingPublicKeyHexes, maxTxFee, optionalTipHex),
function(result) { channelDepositView.setSubmitResult(result) },
function(error) { channelDepositView.setSubmitResult("Error: " + error) }
)
}
onCopyToClipboard: (text) => {
if (root.backend) root.backend.copyToClipboard(text)
}
}
}
}
}
}

View File

@ -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: "<hex>", value: "<u64-string>" }. 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
}
}
}

View File

@ -0,0 +1,514 @@
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 metadataHex, 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 setNotesResult(jsonStr) {
noteSelector.loading = false
var s = jsonStr || ""
if (s.indexOf("Error") === 0) {
noteSelector.errorText = s
noteSelector.notes = []
return
}
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 setSubmitResult(resultStr) {
d.resultPending = false
var s = resultStr || ""
d.resultSuccess = (s.indexOf("Error") !== 0)
d.resultText = s
}
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 })
}
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
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 hex"), 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 hex (optional)")
font.pixelSize: Theme.typography.secondaryText
}
LogosTextField {
id: metadataField
Layout.fillWidth: true
placeholderText: qsTr("Metadata hex")
}
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()
}
}
}

View File

@ -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
SetConfigPathView 1.0 SetConfigPathView.qml
ChannelDepositView 1.0 ChannelDepositView.qml