8 changed files with 513 additions and 15 deletions
@ -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() |
||||||
|
} |
||||||
@ -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) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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 [] |
||||||
@ -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() |
||||||
Loading…
Reference in new issue