You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
468 lines
16 KiB
468 lines
16 KiB
import asyncio |
|
import binascii |
|
from typing import TYPE_CHECKING |
|
|
|
from kivy.lang import Builder |
|
from kivy.factory import Factory |
|
from kivy.uix.popup import Popup |
|
from kivy.clock import Clock |
|
|
|
from electrum.util import bh2u |
|
from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id |
|
from electrum.gui.kivy.i18n import _ |
|
from .question import Question |
|
|
|
if TYPE_CHECKING: |
|
from ...main_window import ElectrumWindow |
|
|
|
|
|
Builder.load_string(r''' |
|
<LightningChannelItem@CardItem> |
|
details: {} |
|
active: False |
|
short_channel_id: '<channelId not set>' |
|
status: '' |
|
local_balance: '' |
|
remote_balance: '' |
|
_chan: None |
|
BoxLayout: |
|
spacing: '8dp' |
|
height: '32dp' |
|
orientation: 'vertical' |
|
Widget |
|
CardLabel: |
|
color: (.5,.5,.5,1) if not root.active else (1,1,1,1) |
|
text: root.short_channel_id |
|
font_size: '15sp' |
|
Widget |
|
CardLabel: |
|
font_size: '13sp' |
|
shorten: True |
|
text: root.status |
|
Widget |
|
BoxLayout: |
|
spacing: '8dp' |
|
height: '32dp' |
|
orientation: 'vertical' |
|
Widget |
|
CardLabel: |
|
text: root.local_balance |
|
font_size: '13sp' |
|
halign: 'right' |
|
Widget |
|
CardLabel: |
|
text: root.remote_balance |
|
font_size: '13sp' |
|
halign: 'right' |
|
Widget |
|
|
|
<LightningChannelsDialog@Popup>: |
|
name: 'lightning_channels' |
|
title: _('Lightning channels.') |
|
can_send: '' |
|
can_receive: '' |
|
id: popup |
|
BoxLayout: |
|
id: box |
|
orientation: 'vertical' |
|
spacing: '2dp' |
|
padding: '12dp' |
|
BoxLabel: |
|
text: _('Can send') + ':' |
|
value: root.can_send |
|
BoxLabel: |
|
text: _('Can receive') + ':' |
|
value: root.can_receive |
|
ScrollView: |
|
GridLayout: |
|
cols: 1 |
|
id: lightning_channels_container |
|
size_hint: 1, None |
|
height: self.minimum_height |
|
spacing: '2dp' |
|
BoxLayout: |
|
size_hint: 1, None |
|
height: '48dp' |
|
Widget: |
|
size_hint: 0.7, None |
|
height: '48dp' |
|
Button: |
|
size_hint: 0.3, None |
|
height: '48dp' |
|
text: _('New...') |
|
on_press: popup.app.popup_dialog('lightning_open_channel_dialog') |
|
|
|
|
|
<ChannelDetailsPopup@Popup>: |
|
id: popuproot |
|
data: [] |
|
is_closed: False |
|
is_redeemed: False |
|
node_id:'' |
|
short_id:'' |
|
initiator:'' |
|
capacity:'' |
|
funding_txid:'' |
|
closing_txid:'' |
|
state:'' |
|
local_ctn:0 |
|
remote_ctn:0 |
|
local_csv:0 |
|
remote_csv:0 |
|
feerate:0 |
|
can_send:'' |
|
can_receive:'' |
|
is_open:False |
|
BoxLayout: |
|
padding: '12dp', '12dp', '12dp', '12dp' |
|
spacing: '12dp' |
|
orientation: 'vertical' |
|
ScrollView: |
|
scroll_type: ['bars', 'content'] |
|
scroll_wheel_distance: dp(114) |
|
BoxLayout: |
|
orientation: 'vertical' |
|
height: self.minimum_height |
|
size_hint_y: None |
|
spacing: '5dp' |
|
BoxLabel: |
|
text: _('Channel ID') |
|
value: root.short_id |
|
BoxLabel: |
|
text: _('State') |
|
value: root.state |
|
BoxLabel: |
|
text: _('Initiator') |
|
value: root.initiator |
|
BoxLabel: |
|
text: _('Capacity') |
|
value: root.capacity |
|
BoxLabel: |
|
text: _('Can send') |
|
value: root.can_send if root.is_open else 'n/a' |
|
BoxLabel: |
|
text: _('Can receive') |
|
value: root.can_receive if root.is_open else 'n/a' |
|
BoxLabel: |
|
text: _('CSV delay') |
|
value: 'Local: %d\nRemote: %d' % (root.local_csv, root.remote_csv) |
|
BoxLabel: |
|
text: _('CTN') |
|
value: 'Local: %d\nRemote: %d' % (root.local_ctn, root.remote_ctn) |
|
BoxLabel: |
|
text: _('Fee rate') |
|
value: '%d sat/kilobyte' % (root.feerate) |
|
Widget: |
|
size_hint: 1, 0.1 |
|
TopLabel: |
|
text: _('Remote Node ID') |
|
TxHashLabel: |
|
data: root.node_id |
|
name: _('Remote Node ID') |
|
TopLabel: |
|
text: _('Funding Transaction') |
|
TxHashLabel: |
|
data: root.funding_txid |
|
name: _('Funding Transaction') |
|
touch_callback: lambda: app.show_transaction(root.funding_txid) |
|
TopLabel: |
|
text: _('Closing Transaction') |
|
opacity: int(bool(root.closing_txid)) |
|
TxHashLabel: |
|
opacity: int(bool(root.closing_txid)) |
|
data: root.closing_txid |
|
name: _('Closing Transaction') |
|
touch_callback: lambda: app.show_transaction(root.closing_txid) |
|
Widget: |
|
size_hint: 1, 0.1 |
|
Widget: |
|
size_hint: 1, 0.05 |
|
BoxLayout: |
|
size_hint: 1, None |
|
height: '48dp' |
|
Button: |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
text: _('Backup') |
|
on_release: root.export_backup() |
|
Button: |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
text: _('Close') |
|
on_release: root.close() |
|
disabled: root.is_closed |
|
Button: |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
text: _('Force-close') |
|
on_release: root.force_close() |
|
disabled: root.is_closed |
|
Button: |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
text: _('Delete') |
|
on_release: root.remove_channel() |
|
disabled: not root.is_redeemed |
|
|
|
<ChannelBackupPopup@Popup>: |
|
id: popuproot |
|
data: [] |
|
is_closed: False |
|
is_redeemed: False |
|
node_id:'' |
|
short_id:'' |
|
initiator:'' |
|
capacity:'' |
|
funding_txid:'' |
|
closing_txid:'' |
|
state:'' |
|
is_open:False |
|
BoxLayout: |
|
padding: '12dp', '12dp', '12dp', '12dp' |
|
spacing: '12dp' |
|
orientation: 'vertical' |
|
ScrollView: |
|
scroll_type: ['bars', 'content'] |
|
scroll_wheel_distance: dp(114) |
|
BoxLayout: |
|
orientation: 'vertical' |
|
height: self.minimum_height |
|
size_hint_y: None |
|
spacing: '5dp' |
|
BoxLabel: |
|
text: _('Channel ID') |
|
value: root.short_id |
|
BoxLabel: |
|
text: _('State') |
|
value: root.state |
|
BoxLabel: |
|
text: _('Initiator') |
|
value: root.initiator |
|
BoxLabel: |
|
text: _('Capacity') |
|
value: root.capacity |
|
Widget: |
|
size_hint: 1, 0.1 |
|
TopLabel: |
|
text: _('Remote Node ID') |
|
TxHashLabel: |
|
data: root.node_id |
|
name: _('Remote Node ID') |
|
TopLabel: |
|
text: _('Funding Transaction') |
|
TxHashLabel: |
|
data: root.funding_txid |
|
name: _('Funding Transaction') |
|
touch_callback: lambda: app.show_transaction(root.funding_txid) |
|
TopLabel: |
|
text: _('Closing Transaction') |
|
opacity: int(bool(root.closing_txid)) |
|
TxHashLabel: |
|
opacity: int(bool(root.closing_txid)) |
|
data: root.closing_txid |
|
name: _('Closing Transaction') |
|
touch_callback: lambda: app.show_transaction(root.closing_txid) |
|
Widget: |
|
size_hint: 1, 0.1 |
|
Widget: |
|
size_hint: 1, 0.05 |
|
BoxLayout: |
|
size_hint: 1, None |
|
height: '48dp' |
|
Button: |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
text: _('Request force-close') |
|
on_release: root.request_force_close() |
|
disabled: root.is_closed |
|
Button: |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
text: _('Delete') |
|
on_release: root.remove_backup() |
|
''') |
|
|
|
|
|
class ChannelBackupPopup(Popup): |
|
|
|
def __init__(self, chan, app, **kwargs): |
|
super(ChannelBackupPopup,self).__init__(**kwargs) |
|
self.chan = chan |
|
self.app = app |
|
self.short_id = format_short_channel_id(chan.short_channel_id) |
|
self.state = chan.get_state_for_GUI() |
|
self.title = _('Channel Backup') |
|
|
|
def request_force_close(self): |
|
msg = _('Request force close?') |
|
Question(msg, self._request_force_close).open() |
|
|
|
def _request_force_close(self, b): |
|
if not b: |
|
return |
|
loop = self.app.wallet.network.asyncio_loop |
|
coro = asyncio.run_coroutine_threadsafe(self.app.wallet.lnbackups.request_force_close(self.chan.channel_id), loop) |
|
try: |
|
coro.result(5) |
|
self.app.show_info(_('Channel closed')) |
|
except Exception as e: |
|
self.app.show_info(_('Could not close channel: ') + repr(e)) # repr because str(Exception()) == '' |
|
|
|
def remove_backup(self): |
|
msg = _('Delete backup?') |
|
Question(msg, self._remove_backup).open() |
|
|
|
def _remove_backup(self, b): |
|
if not b: |
|
return |
|
self.app.wallet.lnbackups.remove_channel_backup(self.chan.channel_id) |
|
self.dismiss() |
|
|
|
class ChannelDetailsPopup(Popup): |
|
|
|
def __init__(self, chan, app, **kwargs): |
|
super(ChannelDetailsPopup,self).__init__(**kwargs) |
|
self.is_closed = chan.is_closed() |
|
self.is_redeemed = chan.is_redeemed() |
|
self.app = app |
|
self.chan = chan |
|
self.title = _('Channel details') |
|
self.node_id = bh2u(chan.node_id) |
|
self.channel_id = bh2u(chan.channel_id) |
|
self.funding_txid = chan.funding_outpoint.txid |
|
self.short_id = format_short_channel_id(chan.short_channel_id) |
|
self.capacity = self.app.format_amount_and_units(chan.constraints.capacity) |
|
self.state = chan.get_state_for_GUI() |
|
self.local_ctn = chan.get_latest_ctn(LOCAL) |
|
self.remote_ctn = chan.get_latest_ctn(REMOTE) |
|
self.local_csv = chan.config[LOCAL].to_self_delay |
|
self.remote_csv = chan.config[REMOTE].to_self_delay |
|
self.initiator = 'Local' if chan.constraints.is_initiator else 'Remote' |
|
self.feerate = chan.get_latest_feerate(LOCAL) |
|
self.can_send = self.app.format_amount_and_units(chan.available_to_spend(LOCAL) // 1000) |
|
self.can_receive = self.app.format_amount_and_units(chan.available_to_spend(REMOTE) // 1000) |
|
self.is_open = chan.is_open() |
|
closed = chan.get_closing_height() |
|
if closed: |
|
self.closing_txid, closing_height, closing_timestamp = closed |
|
|
|
def close(self): |
|
Question(_('Close channel?'), self._close).open() |
|
|
|
def _close(self, b): |
|
if not b: |
|
return |
|
loop = self.app.wallet.network.asyncio_loop |
|
coro = asyncio.run_coroutine_threadsafe(self.app.wallet.lnworker.close_channel(self.chan.channel_id), loop) |
|
try: |
|
coro.result(5) |
|
self.app.show_info(_('Channel closed')) |
|
except Exception as e: |
|
self.app.show_info(_('Could not close channel: ') + repr(e)) # repr because str(Exception()) == '' |
|
|
|
def remove_channel(self): |
|
msg = _('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.') |
|
Question(msg, self._remove_channel).open() |
|
|
|
def _remove_channel(self, b): |
|
if not b: |
|
return |
|
self.app.wallet.lnworker.remove_channel(self.chan.channel_id) |
|
self.app._trigger_update_history() |
|
self.dismiss() |
|
|
|
def export_backup(self): |
|
text = self.app.wallet.lnworker.export_channel_backup(self.chan.channel_id) |
|
# TODO: some messages are duplicated between Kivy and Qt. |
|
help_text = ' '.join([ |
|
_("Channel backups can be imported in another instance of the same wallet, by scanning this QR code."), |
|
_("Please note that channel backups cannot be used to restore your channels."), |
|
_("If you lose your wallet file, the only thing you can do with a backup is to request your channel to be closed, so that your funds will be sent on-chain."), |
|
]) |
|
self.app.qr_dialog(_("Channel Backup " + self.chan.short_id_for_GUI()), text, help_text=help_text) |
|
|
|
def force_close(self): |
|
Question(_('Force-close channel?'), self._force_close).open() |
|
|
|
def _force_close(self, b): |
|
if not b: |
|
return |
|
if self.chan.is_closed(): |
|
self.app.show_error(_('Channel already closed')) |
|
return |
|
loop = self.app.wallet.network.asyncio_loop |
|
coro = asyncio.run_coroutine_threadsafe(self.app.wallet.lnworker.force_close_channel(self.chan.channel_id), loop) |
|
try: |
|
coro.result(1) |
|
self.app.show_info(_('Channel closed, you may need to wait at least {} blocks, because of CSV delays'.format(self.chan.config[REMOTE].to_self_delay))) |
|
except Exception as e: |
|
self.app.show_info(_('Could not force close channel: ') + repr(e)) # repr because str(Exception()) == '' |
|
|
|
|
|
class LightningChannelsDialog(Factory.Popup): |
|
|
|
def __init__(self, app: 'ElectrumWindow'): |
|
super(LightningChannelsDialog, self).__init__() |
|
self.clocks = [] |
|
self.app = app |
|
self.can_send = '' |
|
self.update() |
|
|
|
def show_item(self, obj): |
|
chan = obj._chan |
|
if chan.is_backup(): |
|
p = ChannelBackupPopup(chan, self.app) |
|
else: |
|
p = ChannelDetailsPopup(chan, self.app) |
|
p.open() |
|
|
|
def format_fields(self, chan): |
|
labels = {} |
|
for subject in (REMOTE, LOCAL): |
|
bal_minus_htlcs = chan.balance_minus_outgoing_htlcs(subject)//1000 |
|
label = self.app.format_amount(bal_minus_htlcs) |
|
other = subject.inverted() |
|
bal_other = chan.balance(other)//1000 |
|
bal_minus_htlcs_other = chan.balance_minus_outgoing_htlcs(other)//1000 |
|
if bal_other != bal_minus_htlcs_other: |
|
label += ' (+' + self.app.format_amount(bal_other - bal_minus_htlcs_other) + ')' |
|
labels[subject] = label |
|
closed = chan.is_closed() |
|
return [ |
|
'n/a' if closed else labels[LOCAL], |
|
'n/a' if closed else labels[REMOTE], |
|
] |
|
|
|
def update_item(self, item): |
|
chan = item._chan |
|
item.status = chan.get_state_for_GUI() |
|
item.short_channel_id = chan.short_id_for_GUI() |
|
l, r = self.format_fields(chan) |
|
item.local_balance = _('Local') + ':' + l |
|
item.remote_balance = _('Remote') + ': ' + r |
|
self.update_can_send() |
|
|
|
def update(self): |
|
channel_cards = self.ids.lightning_channels_container |
|
channel_cards.clear_widgets() |
|
if not self.app.wallet: |
|
return |
|
lnworker = self.app.wallet.lnworker |
|
channels = list(lnworker.channels.values()) if lnworker else [] |
|
lnbackups = self.app.wallet.lnbackups |
|
backups = list(lnbackups.channel_backups.values()) |
|
for i in channels + backups: |
|
item = Factory.LightningChannelItem() |
|
item.screen = self |
|
item.active = i.node_id in (lnworker.peers if lnworker else []) |
|
item._chan = i |
|
self.update_item(item) |
|
channel_cards.add_widget(item) |
|
self.update_can_send() |
|
|
|
def update_can_send(self): |
|
lnworker = self.app.wallet.lnworker |
|
if not lnworker: |
|
return |
|
self.can_send = self.app.format_amount_and_units(lnworker.num_sats_can_send()) |
|
self.can_receive = self.app.format_amount_and_units(lnworker.num_sats_can_receive())
|
|
|