diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml new file mode 100644 index 000000000..1939c6736 --- /dev/null +++ b/electrum/gui/qml/components/Channels.qml @@ -0,0 +1,132 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + property string title: qsTr("Lightning Channels") + + ColumnLayout { + id: layout + width: parent.width + height: parent.height + + GridLayout { + id: summaryLayout + Layout.preferredWidth: parent.width + columns: 2 + + Label { + Layout.columnSpan: 2 + text: '' + } + + Label { + text: qsTr('You can send:') + color: Material.accentColor + } + + Label { + text: '' + } + + Label { + text: qsTr('You can receive:') + color: Material.accentColor + } + + Label { + text: '' + } + + RowLayout { + Layout.columnSpan: 2 + + Button { + text: qsTr('Open Channel') + onClicked: app.stack.push(Qt.resolvedUrl('OpenChannel.qml')) + } + } + } + + + Frame { + id: channelsFrame + Layout.preferredWidth: parent.width + Layout.fillHeight: true + verticalPadding: 0 + horizontalPadding: 0 + background: PaneInsetBackground {} + + ColumnLayout { + spacing: 0 + anchors.fill: parent + + Item { + Layout.preferredHeight: hitem.height + Layout.preferredWidth: parent.width + Rectangle { + anchors.fill: parent + color: Qt.lighter(Material.background, 1.25) + } + RowLayout { + id: hitem + width: parent.width + Label { + text: qsTr('Channels') + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor + } + } + } + + ListView { + id: listview + Layout.preferredWidth: parent.width + Layout.fillHeight: true + clip: true + model: 3 //Daemon.currentWallet.channelsModel + + delegate: ItemDelegate { + width: ListView.view.width + height: row.height + highlighted: ListView.isCurrentItem + + font.pixelSize: constants.fontSizeMedium // set default font size for child controls + + RowLayout { + id: row + spacing: 10 + x: constants.paddingSmall + width: parent.width - 2 * constants.paddingSmall + + Image { + id: walleticon + source: "../../icons/lightning.png" + fillMode: Image.PreserveAspectFit + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + } + + Label { + font.pixelSize: constants.fontSizeLarge + text: index + Layout.fillWidth: true + } + + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + } + } + } + + } + + Component.onCompleted: Daemon.currentWallet.channelModel.init_model() +} diff --git a/electrum/gui/qml/components/OpenChannel.qml b/electrum/gui/qml/components/OpenChannel.qml new file mode 100644 index 000000000..369b0da11 --- /dev/null +++ b/electrum/gui/qml/components/OpenChannel.qml @@ -0,0 +1,145 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + + property string title: qsTr("Open Lightning Channel") + + GridLayout { + id: form + width: parent.width + rowSpacing: constants.paddingSmall + columnSpacing: constants.paddingSmall + columns: 4 + + Label { + text: qsTr('Node') + } + + TextArea { + id: node + Layout.columnSpan: 2 + Layout.fillWidth: true + font.family: FixedFont + wrapMode: Text.Wrap + placeholderText: qsTr('Paste or scan node uri/pubkey') + onActiveFocusChanged: { + if (!activeFocus) + channelopener.nodeid = text + } + } + + RowLayout { + spacing: 0 + ToolButton { + icon.source: '../../icons/paste.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + onClicked: { + channelopener.nodeid = AppController.clipboardToText() + node.text = channelopener.nodeid + } + } + ToolButton { + icon.source: '../../icons/qrcode.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + scale: 1.2 + onClicked: { + var page = app.stack.push(Qt.resolvedUrl('Scan.qml')) + page.onFound.connect(function() { + channelopener.nodeid = page.scanData + node.text = channelopener.nodeid + }) + } + } + } + + Label { + text: qsTr('Amount') + } + + BtcField { + id: amount + fiatfield: amountFiat + Layout.preferredWidth: parent.width /2 + onTextChanged: channelopener.amount = Config.unitsToSats(amount.text) + enabled: !is_max.checked + } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + Label { + text: Config.baseUnit + color: Material.accentColor + } + Switch { + id: is_max + text: qsTr('Max') + onCheckedChanged: { + if (checked) { + channelopener.amount = MAX + } + } + } + } + + Item { width: 1; height: 1; visible: Daemon.fx.enabled } + + FiatField { + id: amountFiat + btcfield: amount + visible: Daemon.fx.enabled + Layout.preferredWidth: parent.width /2 + enabled: !is_max.checked + } + + Label { + visible: Daemon.fx.enabled + text: Daemon.fx.fiatCurrency + color: Material.accentColor + Layout.fillWidth: true + } + + Item { visible: Daemon.fx.enabled ; height: 1; width: 1 } + + RowLayout { + Layout.columnSpan: 4 + Layout.alignment: Qt.AlignHCenter + + Button { + text: qsTr('Open Channel') + enabled: channelopener.valid + onClicked: channelopener.open_channel() + } + } + } + + + ChannelOpener { + id: channelopener + wallet: Daemon.currentWallet + onValidationError: { + if (code == 'invalid_nodeid') { + var dialog = app.messageDialog.createObject(root, { 'text': message }) + dialog.open() + } + } + onConflictingBackup: { + var dialog = app.messageDialog.createObject(root, { 'text': message }) + dialog.open() + dialog.yesClicked.connect(function() { + channelopener.open_channel(true) + }) + } + } + +} diff --git a/electrum/gui/qml/components/Scan.qml b/electrum/gui/qml/components/Scan.qml index 22cb6443e..d0cb4ea23 100644 --- a/electrum/gui/qml/components/Scan.qml +++ b/electrum/gui/qml/components/Scan.qml @@ -12,7 +12,6 @@ Item { property bool toolbar: false property string scanData - property var invoiceData: undefined property string error signal found @@ -24,16 +23,6 @@ Item { onFound: { scanPage.scanData = scanData - var invoice = bitcoin.parse_uri(scanData) - if (invoice['error']) { - error = invoice['error'] - console.log(error) - app.stack.pop() - return - } - - invoiceData = invoice - console.log(invoiceData['address']) scanPage.found() app.stack.pop() } @@ -46,8 +35,4 @@ Item { text: 'Cancel' onClicked: app.stack.pop() } - - Bitcoin { - id: bitcoin - } } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 4ff3600f8..110a49509 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -35,6 +35,16 @@ Item { icon.source: '../../icons/network.png' } } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Channels'); + enabled: Daemon.currentWallet.isLightning + onTriggered: menu.openPage(Qt.resolvedUrl('Channels.qml')) + icon.source: '../../icons/lightning.png' + } + } + MenuItem { icon.color: 'transparent' action: Action { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 36dfc99d6..9218de865 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -23,6 +23,7 @@ from .qeinvoice import QEInvoice from .qetypes import QEAmount from .qeaddressdetails import QEAddressDetails from .qetxdetails import QETxDetails +from .qechannelopener import QEChannelOpener notification = None @@ -143,6 +144,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') + qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') @@ -165,11 +167,13 @@ class ElectrumQmlApplication(QGuiApplication): self._qenetwork = QENetwork(daemon.network) self._qedaemon = QEDaemon(daemon) self._appController = QEAppController(self._qedaemon) + self._maxAmount = QEAmount(is_max=True) self.context.setContextProperty('AppController', self._appController) self.context.setContextProperty('Config', self._qeconfig) self.context.setContextProperty('Network', self._qenetwork) self.context.setContextProperty('Daemon', self._qedaemon) self.context.setContextProperty('FixedFont', self.fixedFont) + self.context.setContextProperty('MAX', self._maxAmount) self.context.setContextProperty('BUILD', { 'electrum_version': version.ELECTRUM_VERSION, 'apk_version': version.APK_VERSION, diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py new file mode 100644 index 000000000..531ca0203 --- /dev/null +++ b/electrum/gui/qml/qechannellistmodel.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.logging import get_logger +from electrum.util import Satoshis, TxMinedInfo + +from .qetypes import QEAmount + +class QEChannelListModel(QAbstractListModel): + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.channels = [] + + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('cid','state','initiator','capacity','can_send','can_receive', + 'l_csv_delat','r_csv_delay','send_frozen','receive_frozen', + 'type','node_id','funding_tx') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) + + def rowCount(self, index): + return len(self.tx_history) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + tx = self.tx_history[index.row()] + role_index = role - Qt.UserRole + value = tx[self._ROLE_NAMES[role_index]] + if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: + return value + if isinstance(value, Satoshis): + return value.value + if isinstance(value, QEAmount): + return value + return str(value) + + @pyqtSlot() + def init_model(self): + if not self.wallet.lnworker: + self._logger.warning('lnworker should be defined') + return + + channels = self.wallet.lnworker.channels + self._logger.debug(repr(channels)) + #channels = list(lnworker.channels.values()) if lnworker else [] diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py new file mode 100644 index 000000000..dde5c6c24 --- /dev/null +++ b/electrum/gui/qml/qechannelopener.py @@ -0,0 +1,160 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.util import format_time +from electrum.lnutil import extract_nodeid, ConnStringFormatError +from electrum.gui import messages + +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QEChannelOpener(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _wallet = None + _nodeid = None + _amount = QEAmount() + _valid = False + _opentx = None + + validationError = pyqtSignal([str,str], arguments=['code','message']) + conflictingBackup = pyqtSignal([str], arguments=['message']) + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.walletChanged.emit() + + nodeidChanged = pyqtSignal() + @pyqtProperty(str, notify=nodeidChanged) + def nodeid(self): + return self._nodeid + + @nodeid.setter + def nodeid(self, nodeid: str): + if self._nodeid != nodeid: + self._logger.debug('nodeid set -> %s' % nodeid) + self._nodeid = nodeid + self.nodeidChanged.emit() + self.validate() + + amountChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=amountChanged) + def amount(self): + return self._amount + + @amount.setter + def amount(self, amount: QEAmount): + if self._amount != amount: + self._amount = amount + self.amountChanged.emit() + self.validate() + + validChanged = pyqtSignal() + @pyqtProperty(bool, notify=validChanged) + def valid(self): + return self._valid + + openTxChanged = pyqtSignal() + @pyqtProperty(bool, notify=openTxChanged) + def openTx(self): + return self._opentx + + def validate(self): + nodeid_valid = False + if self._nodeid: + try: + self._node_pubkey, self._host_port = extract_nodeid(self._nodeid) + nodeid_valid = True + except ConnStringFormatError as e: + self.validationError.emit('invalid_nodeid', repr(e)) + + if not nodeid_valid: + self._valid = False + self.validChanged.emit() + return + + self._logger.debug('amount=%s' % str(self._amount)) + if not self._amount or not (self._amount.satsInt > 0 or self._amount.isMax): + self._valid = False + self.validChanged.emit() + return + + self._valid = True + self.validChanged.emit() + + # FIXME "max" button in amount_dialog should enforce LN_MAX_FUNDING_SAT + @pyqtSlot() + @pyqtSlot(bool) + def open_channel(self, confirm_backup_conflict=False): + if not self.valid: + return + + #if self.use_gossip: + #conn_str = self.pubkey + #if self.ipport: + #conn_str += '@' + self.ipport.strip() + #else: + #conn_str = str(self.trampolines[self.pubkey]) + amount = '!' if self._amount.isMax else self._amount.satsInt + + lnworker = self._wallet.wallet.lnworker + if lnworker.has_conflicting_backup_with(node_pubkey) and not confirm_backup_conflict: + self.conflictingBackup.emit(messages.MGS_CONFLICTING_BACKUP_INSTANCE) + return + + coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True) + #node_id, rest = extract_nodeid(conn_str) + make_tx = lambda rbf: lnworker.mktx_for_open_channel( + coins=coins, + funding_sat=amount, + node_id=self._node_pubkey, + fee_est=None) + #on_pay = lambda tx: self.app.protected('Create a new channel?', self.do_open_channel, (tx, conn_str)) + #d = ConfirmTxDialog( + #self.app, + #amount = amount, + #make_tx=make_tx, + #on_pay=on_pay, + #show_final=False) + #d.open() + + #def do_open_channel(self, funding_tx, conn_str, password): + ## read funding_sat from tx; converts '!' to int value + #funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) + #lnworker = self.app.wallet.lnworker + #try: + #chan, funding_tx = lnworker.open_channel( + #connect_str=conn_str, + #funding_tx=funding_tx, + #funding_sat=funding_sat, + #push_amt_sat=0, + #password=password) + #except Exception as e: + #self.app.logger.exception("Problem opening channel") + #self.app.show_error(_('Problem opening channel: ') + '\n' + repr(e)) + #return + ## TODO: it would be nice to show this before broadcasting + #if chan.has_onchain_backup(): + #self.maybe_show_funding_tx(chan, funding_tx) + #else: + #title = _('Save backup') + #help_text = _(messages.MSG_CREATED_NON_RECOVERABLE_CHANNEL) + #data = lnworker.export_channel_backup(chan.channel_id) + #popup = QRDialog( + #title, data, + #show_text=False, + #text_for_clipboard=data, + #help_text=help_text, + #close_button_text=_('OK'), + #on_close=lambda: self.maybe_show_funding_tx(chan, funding_tx)) + #popup.open() diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 2b398085f..08c9bc8b7 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -17,6 +17,7 @@ from electrum.invoices import (Invoice, InvoiceError, from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel from .qetransactionlistmodel import QETransactionListModel from .qeaddresslistmodel import QEAddressListModel +from .qechannellistmodel import QEChannelListModel from .qetypes import QEAmount class QEWallet(QObject): @@ -60,6 +61,7 @@ class QEWallet(QObject): self._addressModel = QEAddressListModel(wallet) self._requestModel = QERequestListModel(wallet) self._invoiceModel = QEInvoiceListModel(wallet) + self._channelModel = None self._historyModel.init_model() self._requestModel.init_model() @@ -192,6 +194,13 @@ class QEWallet(QObject): def invoiceModel(self): return self._invoiceModel + channelModelChanged = pyqtSignal() + @pyqtProperty(QEChannelListModel, notify=channelModelChanged) + def channelModel(self): + if self._channelModel is None: + self._channelModel = QEChannelListModel(self.wallet) + return self._channelModel + nameChanged = pyqtSignal() @pyqtProperty('QString', notify=nameChanged) def name(self):