electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

lightning_channels.py (30137B)


      1 import asyncio
      2 from typing import TYPE_CHECKING, Optional, Union
      3 
      4 from kivy.lang import Builder
      5 from kivy.factory import Factory
      6 from kivy.uix.popup import Popup
      7 from .fee_dialog import FeeDialog
      8 
      9 from electrum.util import bh2u
     10 from electrum.logging import Logger
     11 from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id
     12 from electrum.lnchannel import AbstractChannel, Channel
     13 from electrum.gui.kivy.i18n import _
     14 from .question import Question
     15 from electrum.transaction import PartialTxOutput, Transaction
     16 from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, format_fee_satoshis, quantize_feerate
     17 from electrum.lnutil import ln_dummy_address
     18 
     19 if TYPE_CHECKING:
     20     from ...main_window import ElectrumWindow
     21     from electrum import SimpleConfig
     22 
     23 
     24 Builder.load_string(r'''
     25 <SwapDialog@Popup>
     26     id: popup
     27     title: _('Lightning Swap')
     28     size_hint: 0.8, 0.8
     29     pos_hint: {'top':0.9}
     30     mining_fee_text: ''
     31     fee_rate_text: ''
     32     method: 0
     33     BoxLayout:
     34         orientation: 'vertical'
     35         BoxLayout:
     36             orientation: 'horizontal'
     37             size_hint: 1, 0.5
     38             Label:
     39                 text: _('You Send') + ':'
     40                 size_hint: 0.4, 1
     41             Label:
     42                 id: send_amount_label
     43                 size_hint: 0.6, 1
     44                 text: _('0')
     45                 background_color: (0,0,0,0)
     46         BoxLayout:
     47             orientation: 'horizontal'
     48             size_hint: 1, 0.5
     49             Label:
     50                 text: _('You Receive') + ':'
     51                 size_hint: 0.4, 1
     52             Label:
     53                 id: receive_amount_label
     54                 text: _('0')
     55                 background_color: (0,0,0,0)
     56                 size_hint: 0.6, 1
     57         BoxLayout:
     58             orientation: 'horizontal'
     59             size_hint: 1, 0.5
     60             Label:
     61                 text: _('Server Fee') + ':'
     62                 size_hint: 0.4, 1
     63             Label:
     64                 id: server_fee_label
     65                 text: _('0')
     66                 background_color: (0,0,0,0)
     67                 size_hint: 0.6, 1
     68         BoxLayout:
     69             orientation: 'horizontal'
     70             size_hint: 1, 0.5
     71             Label:
     72                 id: swap_action_label
     73                 text: _('Adds receiving capacity')
     74                 background_color: (0,0,0,0)
     75                 font_size: '14dp'
     76         Slider:
     77             id: swap_slider
     78             range: 0, 4
     79             step: 1
     80             on_value: root.swap_slider_moved(self.value)
     81         Widget:
     82             size_hint: 1, 0.5
     83         BoxLayout:
     84             orientation: 'horizontal'
     85             size_hint: 1, 0.5
     86             Label:
     87                 text: _('Mining Fee') + ':'
     88                 size_hint: 0.4, 1
     89             Button:
     90                 text: root.mining_fee_text + ' (' + root.fee_rate_text + ')'
     91                 background_color: (0,0,0,0)
     92                 bold: True
     93                 on_release:
     94                     root.on_fee_button()
     95         Widget:
     96             size_hint: 1, 0.5
     97         BoxLayout:
     98             orientation: 'horizontal'
     99             size_hint: 1, 0.5
    100             TopLabel:
    101                 id: fee_estimate
    102                 text: ''
    103                 font_size: '14dp'
    104         Widget:
    105             size_hint: 1, 0.5
    106         BoxLayout:
    107             orientation: 'horizontal'
    108             size_hint: 1, 0.5
    109             Button:
    110                 text: 'Cancel'
    111                 size_hint: 0.5, None
    112                 height: '48dp'
    113                 on_release: root.dismiss()
    114             Button:
    115                 id: ok_button
    116                 text: 'OK'
    117                 size_hint: 0.5, None
    118                 height: '48dp'
    119                 on_release:
    120                     root.on_ok()
    121                     root.dismiss()
    122 
    123 <LightningChannelItem@CardItem>
    124     details: {}
    125     active: False
    126     short_channel_id: '<channelId not set>'
    127     status: ''
    128     is_backup: False
    129     balances: ''
    130     node_alias: ''
    131     _chan: None
    132     BoxLayout:
    133         size_hint: 0.7, None
    134         spacing: '8dp'
    135         height: '32dp'
    136         orientation: 'vertical'
    137         Widget
    138         CardLabel:
    139             color: (.5,.5,.5,1) if not root.active else (1,1,1,1)
    140             text: root.short_channel_id
    141             font_size: '15sp'
    142         Widget
    143         CardLabel:
    144             font_size: '13sp'
    145             shorten: True
    146             text: root.node_alias
    147         Widget
    148     BoxLayout:
    149         size_hint: 0.3, None
    150         spacing: '8dp'
    151         height: '32dp'
    152         orientation: 'vertical'
    153         Widget
    154         CardLabel:
    155             text: root.status
    156             font_size: '13sp'
    157             halign: 'right'
    158         Widget
    159         CardLabel:
    160             text: root.balances if not root.is_backup else ''
    161             font_size: '13sp'
    162             halign: 'right'
    163         Widget
    164 
    165 <LightningChannelsDialog@Popup>:
    166     name: 'lightning_channels'
    167     title: _('Lightning Network')
    168     has_lightning: False
    169     has_gossip: False
    170     can_send: ''
    171     can_receive: ''
    172     num_channels_text: ''
    173     id: popup
    174     BoxLayout:
    175         id: box
    176         orientation: 'vertical'
    177         spacing: '2dp'
    178         padding: '12dp'
    179         BoxLabel:
    180             text: _('You can send') + ':'
    181             value: root.can_send
    182         BoxLabel:
    183             text: _('You can receive') + ':'
    184             value: root.can_receive
    185         TopLabel:
    186             text: root.num_channels_text
    187         ScrollView:
    188             GridLayout:
    189                 cols: 1
    190                 id: lightning_channels_container
    191                 size_hint: 1, None
    192                 height: self.minimum_height
    193                 spacing: '2dp'
    194         BoxLayout:
    195             size_hint: 1, None
    196             height: '48dp'
    197             Button:
    198                 size_hint: 0.3, None
    199                 height: '48dp'
    200                 text: _('Open Channel')
    201                 disabled: not root.has_lightning
    202                 on_release: popup.app.popup_dialog('lightning_open_channel_dialog')
    203             Button:
    204                 size_hint: 0.3, None
    205                 height: '48dp'
    206                 text: _('Swap')
    207                 disabled: not root.has_lightning
    208                 on_release: popup.app.popup_dialog('swap_dialog')
    209             Button:
    210                 size_hint: 0.3, None
    211                 height: '48dp'
    212                 text: _('Gossip')
    213                 disabled: not root.has_gossip
    214                 on_release: popup.app.popup_dialog('lightning')
    215 
    216 
    217 <ChannelDetailsPopup@Popup>:
    218     id: popuproot
    219     data: []
    220     is_closed: False
    221     is_redeemed: False
    222     node_id:''
    223     short_id:''
    224     initiator:''
    225     capacity:''
    226     funding_txid:''
    227     closing_txid:''
    228     state:''
    229     local_ctn:0
    230     remote_ctn:0
    231     local_csv:0
    232     remote_csv:0
    233     feerate:''
    234     can_send:''
    235     can_receive:''
    236     is_open:False
    237     warning: ''
    238     BoxLayout:
    239         padding: '12dp', '12dp', '12dp', '12dp'
    240         spacing: '12dp'
    241         orientation: 'vertical'
    242         ScrollView:
    243             scroll_type: ['bars', 'content']
    244             scroll_wheel_distance: dp(114)
    245             BoxLayout:
    246                 orientation: 'vertical'
    247                 height: self.minimum_height
    248                 size_hint_y: None
    249                 spacing: '5dp'
    250                 TopLabel:
    251                     text: root.warning
    252                     color: .905, .709, .509, 1
    253                 BoxLabel:
    254                     text: _('Channel ID')
    255                     value: root.short_id
    256                 BoxLabel:
    257                     text: _('State')
    258                     value: root.state
    259                 BoxLabel:
    260                     text: _('Initiator')
    261                     value: root.initiator
    262                 BoxLabel:
    263                     text: _('Capacity')
    264                     value: root.capacity
    265                 BoxLabel:
    266                     text: _('Can send')
    267                     value: root.can_send if root.is_open else 'n/a'
    268                 BoxLabel:
    269                     text: _('Can receive')
    270                     value: root.can_receive if root.is_open else 'n/a'
    271                 BoxLabel:
    272                     text: _('CSV delay')
    273                     value: 'Local: %d\nRemote: %d' % (root.local_csv, root.remote_csv)
    274                 BoxLabel:
    275                     text: _('CTN')
    276                     value: 'Local: %d\nRemote: %d' % (root.local_ctn, root.remote_ctn)
    277                 BoxLabel:
    278                     text: _('Fee rate')
    279                     value: '{} sat/byte'.format(root.feerate)
    280                 Widget:
    281                     size_hint: 1, 0.1
    282                 TopLabel:
    283                     text: _('Remote Node ID')
    284                 TxHashLabel:
    285                     data: root.node_id
    286                     name: _('Remote Node ID')
    287                 TopLabel:
    288                     text: _('Funding Transaction')
    289                 TxHashLabel:
    290                     data: root.funding_txid
    291                     name: _('Funding Transaction')
    292                     touch_callback: lambda: app.show_transaction(root.funding_txid)
    293                 TopLabel:
    294                     text: _('Closing Transaction')
    295                     opacity: int(bool(root.closing_txid))
    296                 TxHashLabel:
    297                     opacity: int(bool(root.closing_txid))
    298                     data: root.closing_txid
    299                     name: _('Closing Transaction')
    300                     touch_callback: lambda: app.show_transaction(root.closing_txid)
    301                 Widget:
    302                     size_hint: 1, 0.1
    303         Widget:
    304             size_hint: 1, 0.05
    305         BoxLayout:
    306             size_hint: 1, None
    307             height: '48dp'
    308             Button:
    309                 size_hint: 0.5, None
    310                 height: '48dp'
    311                 text: _('Backup')
    312                 on_release: root.export_backup()
    313             Button:
    314                 size_hint: 0.5, None
    315                 height: '48dp'
    316                 text: _('Close')
    317                 on_release: root.close()
    318                 disabled: root.is_closed
    319             Button:
    320                 size_hint: 0.5, None
    321                 height: '48dp'
    322                 text: _('Force-close')
    323                 on_release: root.force_close()
    324                 disabled: root.is_closed
    325             Button:
    326                 size_hint: 0.5, None
    327                 height: '48dp'
    328                 text: _('Delete')
    329                 on_release: root.remove_channel()
    330                 disabled: not root.is_redeemed
    331 
    332 <ChannelBackupPopup@Popup>:
    333     id: popuproot
    334     data: []
    335     is_closed: False
    336     is_redeemed: False
    337     node_id:''
    338     short_id:''
    339     initiator:''
    340     capacity:''
    341     funding_txid:''
    342     closing_txid:''
    343     state:''
    344     is_open:False
    345     BoxLayout:
    346         padding: '12dp', '12dp', '12dp', '12dp'
    347         spacing: '12dp'
    348         orientation: 'vertical'
    349         ScrollView:
    350             scroll_type: ['bars', 'content']
    351             scroll_wheel_distance: dp(114)
    352             BoxLayout:
    353                 orientation: 'vertical'
    354                 height: self.minimum_height
    355                 size_hint_y: None
    356                 spacing: '5dp'
    357                 BoxLabel:
    358                     text: _('Channel ID')
    359                     value: root.short_id
    360                 BoxLabel:
    361                     text: _('State')
    362                     value: root.state
    363                 BoxLabel:
    364                     text: _('Initiator')
    365                     value: root.initiator
    366                 BoxLabel:
    367                     text: _('Capacity')
    368                     value: root.capacity
    369                 Widget:
    370                     size_hint: 1, 0.1
    371                 TopLabel:
    372                     text: _('Remote Node ID')
    373                 TxHashLabel:
    374                     data: root.node_id
    375                     name: _('Remote Node ID')
    376                 TopLabel:
    377                     text: _('Funding Transaction')
    378                 TxHashLabel:
    379                     data: root.funding_txid
    380                     name: _('Funding Transaction')
    381                     touch_callback: lambda: app.show_transaction(root.funding_txid)
    382                 TopLabel:
    383                     text: _('Closing Transaction')
    384                     opacity: int(bool(root.closing_txid))
    385                 TxHashLabel:
    386                     opacity: int(bool(root.closing_txid))
    387                     data: root.closing_txid
    388                     name: _('Closing Transaction')
    389                     touch_callback: lambda: app.show_transaction(root.closing_txid)
    390                 Widget:
    391                     size_hint: 1, 0.1
    392         Widget:
    393             size_hint: 1, 0.05
    394         BoxLayout:
    395             size_hint: 1, None
    396             height: '48dp'
    397             Button:
    398                 size_hint: 0.5, None
    399                 height: '48dp'
    400                 text: _('Request force-close')
    401                 on_release: root.request_force_close()
    402                 disabled: root.is_closed
    403             Button:
    404                 size_hint: 0.5, None
    405                 height: '48dp'
    406                 text: _('Delete')
    407                 on_release: root.remove_backup()
    408 ''')
    409 
    410 
    411 class ChannelBackupPopup(Popup, Logger):
    412 
    413     def __init__(self, chan: AbstractChannel, channels_list, **kwargs):
    414         Popup.__init__(self, **kwargs)
    415         Logger.__init__(self)
    416         self.chan = chan
    417         self.channels_list = channels_list
    418         self.app = channels_list.app
    419         self.short_id = format_short_channel_id(chan.short_channel_id)
    420         self.state = chan.get_state_for_GUI()
    421         self.title = _('Channel Backup')
    422 
    423     def request_force_close(self):
    424         msg = _('Request force close?')
    425         Question(msg, self._request_force_close).open()
    426 
    427     def _request_force_close(self, b):
    428         if not b:
    429             return
    430         loop = self.app.wallet.network.asyncio_loop
    431         coro = asyncio.run_coroutine_threadsafe(self.app.wallet.lnworker.request_force_close_from_backup(self.chan.channel_id), loop)
    432         try:
    433             coro.result(5)
    434             self.app.show_info(_('Channel closed'))
    435         except Exception as e:
    436             self.logger.exception("Could not close channel")
    437             self.app.show_info(_('Could not close channel: ') + repr(e)) # repr because str(Exception()) == ''
    438 
    439     def remove_backup(self):
    440         msg = _('Delete backup?')
    441         Question(msg, self._remove_backup).open()
    442 
    443     def _remove_backup(self, b):
    444         if not b:
    445             return
    446         self.app.wallet.lnworker.remove_channel_backup(self.chan.channel_id)
    447         self.dismiss()
    448 
    449 
    450 class ChannelDetailsPopup(Popup, Logger):
    451 
    452     def __init__(self, chan: Channel, app: 'ElectrumWindow', **kwargs):
    453         Popup.__init__(self, **kwargs)
    454         Logger.__init__(self)
    455         self.is_closed = chan.is_closed()
    456         self.is_redeemed = chan.is_redeemed()
    457         self.app = app
    458         self.chan = chan
    459         self.title = _('Channel details')
    460         self.node_id = bh2u(chan.node_id)
    461         self.channel_id = bh2u(chan.channel_id)
    462         self.funding_txid = chan.funding_outpoint.txid
    463         self.short_id = format_short_channel_id(chan.short_channel_id)
    464         self.capacity = self.app.format_amount_and_units(chan.get_capacity())
    465         self.state = chan.get_state_for_GUI()
    466         self.local_ctn = chan.get_latest_ctn(LOCAL)
    467         self.remote_ctn = chan.get_latest_ctn(REMOTE)
    468         self.local_csv = chan.config[LOCAL].to_self_delay
    469         self.remote_csv = chan.config[REMOTE].to_self_delay
    470         self.initiator = 'Local' if chan.constraints.is_initiator else 'Remote'
    471         feerate_kw = chan.get_latest_feerate(LOCAL)
    472         self.feerate = str(quantize_feerate(Transaction.satperbyte_from_satperkw(feerate_kw)))
    473         self.can_send = self.app.format_amount_and_units(chan.available_to_spend(LOCAL) // 1000)
    474         self.can_receive = self.app.format_amount_and_units(chan.available_to_spend(REMOTE) // 1000)
    475         self.is_open = chan.is_open()
    476         closed = chan.get_closing_height()
    477         if closed:
    478             self.closing_txid, closing_height, closing_timestamp = closed
    479         msg = ' '.join([
    480             _("Trampoline routing is enabled, but this channel is with a non-trampoline node."),
    481             _("This channel may still be used for receiving, but it is frozen for sending."),
    482             _("If you want to keep using this channel, you need to disable trampoline routing in your preferences."),
    483         ])
    484         self.warning = '' if self.app.wallet.lnworker.channel_db or self.app.wallet.lnworker.is_trampoline_peer(chan.node_id) else _('Warning') + ': ' + msg
    485 
    486     def close(self):
    487         Question(_('Close channel?'), self._close).open()
    488 
    489     def _close(self, b):
    490         if not b:
    491             return
    492         loop = self.app.wallet.network.asyncio_loop
    493         coro = asyncio.run_coroutine_threadsafe(self.app.wallet.lnworker.close_channel(self.chan.channel_id), loop)
    494         try:
    495             coro.result(5)
    496             self.app.show_info(_('Channel closed'))
    497         except Exception as e:
    498             self.logger.exception("Could not close channel")
    499             self.app.show_info(_('Could not close channel: ') + repr(e)) # repr because str(Exception()) == ''
    500 
    501     def remove_channel(self):
    502         msg = _('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.')
    503         Question(msg, self._remove_channel).open()
    504 
    505     def _remove_channel(self, b):
    506         if not b:
    507             return
    508         self.app.wallet.lnworker.remove_channel(self.chan.channel_id)
    509         self.app._trigger_update_history()
    510         self.dismiss()
    511 
    512     def export_backup(self):
    513         text = self.app.wallet.lnworker.export_channel_backup(self.chan.channel_id)
    514         # TODO: some messages are duplicated between Kivy and Qt.
    515         help_text = ' '.join([
    516             _("Channel backups can be imported in another instance of the same wallet, by scanning this QR code."),
    517             _("Please note that channel backups cannot be used to restore your channels."),
    518             _("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."),
    519         ])
    520         self.app.qr_dialog(_("Channel Backup " + self.chan.short_id_for_GUI()), text, help_text=help_text)
    521 
    522     def force_close(self):
    523         Question(_('Force-close channel?'), self._force_close).open()
    524 
    525     def _force_close(self, b):
    526         if not b:
    527             return
    528         if self.chan.is_closed():
    529             self.app.show_error(_('Channel already closed'))
    530             return
    531         loop = self.app.wallet.network.asyncio_loop
    532         coro = asyncio.run_coroutine_threadsafe(self.app.wallet.lnworker.force_close_channel(self.chan.channel_id), loop)
    533         try:
    534             coro.result(1)
    535             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)))
    536         except Exception as e:
    537             self.logger.exception("Could not force close channel")
    538             self.app.show_info(_('Could not force close channel: ') + repr(e)) # repr because str(Exception()) == ''
    539 
    540 
    541 class LightningChannelsDialog(Factory.Popup):
    542 
    543     def __init__(self, app: 'ElectrumWindow'):
    544         super(LightningChannelsDialog, self).__init__()
    545         self.clocks = []
    546         self.app = app
    547         self.has_lightning = app.wallet.has_lightning()
    548         self.has_gossip = self.app.network.channel_db is not None
    549         self.update()
    550 
    551     def show_item(self, obj):
    552         chan = obj._chan
    553         if chan.is_backup():
    554             p = ChannelBackupPopup(chan, self)
    555         else:
    556             p = ChannelDetailsPopup(chan, self)
    557         p.open()
    558 
    559     def format_fields(self, chan):
    560         labels = {}
    561         for subject in (REMOTE, LOCAL):
    562             bal_minus_htlcs = chan.balance_minus_outgoing_htlcs(subject)//1000
    563             label = self.app.format_amount(bal_minus_htlcs)
    564             other = subject.inverted()
    565             bal_other = chan.balance(other)//1000
    566             bal_minus_htlcs_other = chan.balance_minus_outgoing_htlcs(other)//1000
    567             if bal_other != bal_minus_htlcs_other:
    568                 label += ' (+' + self.app.format_amount(bal_other - bal_minus_htlcs_other) + ')'
    569             labels[subject] = label
    570         closed = chan.is_closed()
    571         return [
    572             'n/a' if closed else labels[LOCAL],
    573             'n/a' if closed else labels[REMOTE],
    574         ]
    575 
    576     def update_item(self, item):
    577         chan = item._chan
    578         item.status = chan.get_state_for_GUI()
    579         item.short_channel_id = chan.short_id_for_GUI()
    580         l, r = self.format_fields(chan)
    581         item.balances = l + '/' + r
    582         self.update_can_send()
    583 
    584     def update(self):
    585         channel_cards = self.ids.lightning_channels_container
    586         channel_cards.clear_widgets()
    587         if not self.app.wallet:
    588             return
    589         lnworker = self.app.wallet.lnworker
    590         channels = list(lnworker.channels.values()) if lnworker else []
    591         backups = list(lnworker.channel_backups.values()) if lnworker else []
    592         for i in channels + backups:
    593             item = Factory.LightningChannelItem()
    594             item.screen = self
    595             item.active = not i.is_closed()
    596             item.is_backup = i.is_backup()
    597             item._chan = i
    598             item.node_alias = lnworker.get_node_alias(i.node_id) or i.node_id.hex()
    599             self.update_item(item)
    600             channel_cards.add_widget(item)
    601         self.update_can_send()
    602 
    603     def update_can_send(self):
    604         lnworker = self.app.wallet.lnworker
    605         if not lnworker:
    606             self.can_send = 'n/a'
    607             self.can_receive = 'n/a'
    608             return
    609         self.num_channels_text = _(f'You have {len(lnworker.channels)} channels.')
    610         self.can_send = self.app.format_amount_and_units(lnworker.num_sats_can_send())
    611         self.can_receive = self.app.format_amount_and_units(lnworker.num_sats_can_receive())
    612 
    613 
    614 # Swaps should be done in due time which is why we recommend a certain fee.
    615 RECOMMEND_BLOCKS_SWAP = 25
    616 
    617 
    618 class SwapDialog(Factory.Popup):
    619     def __init__(self, app: 'ElectrumWindow', config: 'SimpleConfig'):
    620         super(SwapDialog, self).__init__()
    621         self.app = app
    622         self.config = config
    623         self.fmt_amt = self.app.format_amount_and_units
    624         self.lnworker = self.app.wallet.lnworker
    625 
    626         # swap related
    627         self.swap_manager = self.lnworker.swap_manager
    628         self.send_amount: Optional[int] = None
    629         self.receive_amount: Optional[int] = None
    630         self.tx = None  # only for forward swap
    631         self.is_reverse = None
    632 
    633         # init swaps and sliders
    634         asyncio.run(self.swap_manager.get_pairs())
    635         self.update_and_init()
    636 
    637     def update_and_init(self):
    638         self.update_fee_text()
    639         self.update_swap_slider()
    640         self.swap_slider_moved(0)
    641 
    642     def on_fee_button(self):
    643         fee_dialog = FeeDialog(self, self.config, self.after_fee_changed)
    644         fee_dialog.open()
    645 
    646     def after_fee_changed(self):
    647         self.update_fee_text()
    648         self.update_swap_slider()
    649         self.swap_slider_moved(self.ids.swap_slider.value)
    650 
    651     def update_fee_text(self):
    652         fee_per_kb = self.config.fee_per_kb()
    653         # eta is -1 when block inclusion cannot be estimated for low fees
    654         eta = self.config.fee_to_eta(fee_per_kb)
    655 
    656         fee_per_b = format_fee_satoshis(fee_per_kb / 1000)
    657         suggest_fee = self.config.eta_target_to_fee(RECOMMEND_BLOCKS_SWAP)
    658         suggest_fee_per_b = format_fee_satoshis(suggest_fee / 1000)
    659 
    660         s = 's' if eta > 1 else ''
    661         if eta > RECOMMEND_BLOCKS_SWAP or eta == -1:
    662             msg = f'Warning: Your fee rate of {fee_per_b} sat/B may be too ' \
    663                   f'low for the swap to succeed before its timeout. ' \
    664                   f'The recommended fee rate is at least {suggest_fee_per_b} ' \
    665                   f'sat/B.'
    666         else:
    667             msg = f'Info: Your swap is estimated to be processed in {eta} ' \
    668                   f'block{s} with an onchain fee rate of {fee_per_b} sat/B.'
    669 
    670         self.fee_rate_text = f'{fee_per_b} sat/B'
    671         self.ids.fee_estimate.text = msg
    672 
    673     def update_tx(self, onchain_amount: Union[int, str]):
    674         """Updates the transaction associated with a forward swap."""
    675         if onchain_amount is None:
    676             self.tx = None
    677             self.ids.ok_button.disabled = True
    678             return
    679         outputs = [PartialTxOutput.from_address_and_value(ln_dummy_address(), onchain_amount)]
    680         coins = self.app.wallet.get_spendable_coins(None)
    681         try:
    682             self.tx = self.app.wallet.make_unsigned_transaction(
    683                 coins=coins,
    684                 outputs=outputs)
    685         except (NotEnoughFunds, NoDynamicFeeEstimates):
    686             self.tx = None
    687             self.ids.ok_button.disabled = True
    688 
    689     def update_swap_slider(self):
    690         """Sets the minimal and maximal amount that can be swapped for the swap
    691         slider."""
    692         # tx is updated again afterwards with send_amount in case of normal swap
    693         # this is just to estimate the maximal spendable onchain amount for HTLC
    694         self.update_tx('!')
    695         try:
    696             max_onchain_spend = self.tx.output_value_for_address(ln_dummy_address())
    697         except AttributeError:  # happens if there are no utxos
    698             max_onchain_spend = 0
    699         reverse = int(min(self.lnworker.num_sats_can_send(),
    700                           self.swap_manager.get_max_amount()))
    701         forward = int(min(self.lnworker.num_sats_can_receive(),
    702                           # maximally supported swap amount by provider
    703                           self.swap_manager.get_max_amount(),
    704                           max_onchain_spend))
    705         # we expect range to adjust the value of the swap slider to be in the
    706         # correct range, i.e., to correct an overflow when reducing the limits
    707         self.ids.swap_slider.range = (-reverse, forward)
    708 
    709     def swap_slider_moved(self, position: float):
    710         position = int(position)
    711         # pay_amount and receive_amounts are always with fees already included
    712         # so they reflect the net balance change after the swap
    713         if position < 0:  # reverse swap
    714             self.ids.swap_action_label.text = "Adds Lightning receiving capacity."
    715             self.is_reverse = True
    716 
    717             pay_amount = abs(position)
    718             self.send_amount = pay_amount
    719             self.ids.send_amount_label.text = \
    720                 f"{self.fmt_amt(pay_amount)} (offchain)" if pay_amount else ""
    721 
    722             receive_amount = self.swap_manager.get_recv_amount(
    723                 send_amount=pay_amount, is_reverse=True)
    724             self.receive_amount = receive_amount
    725             self.ids.receive_amount_label.text = \
    726                 f"{self.fmt_amt(receive_amount)} (onchain)" if receive_amount else ""
    727 
    728             # fee breakdown
    729             self.ids.server_fee_label.text = \
    730                 f"{self.swap_manager.percentage:0.1f}% + {self.fmt_amt(self.swap_manager.lockup_fee)}"
    731             self.mining_fee_text = \
    732                 f"{self.fmt_amt(self.swap_manager.get_claim_fee())}"
    733 
    734         else:  # forward (normal) swap
    735             self.ids.swap_action_label.text = f"Adds Lightning sending capacity."
    736             self.is_reverse = False
    737             self.send_amount = position
    738 
    739             self.update_tx(self.send_amount)
    740             # add lockup fees, but the swap amount is position
    741             pay_amount = position + self.tx.get_fee() if self.tx else 0
    742             self.ids.send_amount_label.text = \
    743                 f"{self.fmt_amt(pay_amount)} (onchain)" if self.fmt_amt(pay_amount) else ""
    744 
    745             receive_amount = self.swap_manager.get_recv_amount(
    746                 send_amount=position, is_reverse=False)
    747             self.receive_amount = receive_amount
    748             self.ids.receive_amount_label.text = \
    749                 f"{self.fmt_amt(receive_amount)} (offchain)" if receive_amount else ""
    750 
    751             # fee breakdown
    752             self.ids.server_fee_label.text = \
    753                 f"{self.swap_manager.percentage:0.1f}% + {self.fmt_amt(self.swap_manager.normal_fee)}"
    754             self.mining_fee_text = \
    755                 f"{self.fmt_amt(self.tx.get_fee())}" if self.tx else ""
    756 
    757         if pay_amount and receive_amount:
    758             self.ids.ok_button.disabled = False
    759         else:
    760             # add more nuanced error reporting?
    761             self.ids.swap_action_label.text = "Swap below minimal swap size, change the slider."
    762             self.ids.ok_button.disabled = True
    763 
    764     def do_normal_swap(self, lightning_amount, onchain_amount, password):
    765         tx = self.tx
    766         assert tx
    767         if lightning_amount is None or onchain_amount is None:
    768             return
    769         loop = self.app.network.asyncio_loop
    770         coro = self.swap_manager.normal_swap(
    771             lightning_amount_sat=lightning_amount,
    772             expected_onchain_amount_sat=onchain_amount,
    773             password=password,
    774             tx=tx,
    775         )
    776         asyncio.run_coroutine_threadsafe(coro, loop)
    777 
    778     def do_reverse_swap(self, lightning_amount, onchain_amount, password):
    779         if lightning_amount is None or onchain_amount is None:
    780             return
    781         loop = self.app.network.asyncio_loop
    782         coro = self.swap_manager.reverse_swap(
    783             lightning_amount_sat=lightning_amount,
    784             expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_claim_fee(),
    785         )
    786         asyncio.run_coroutine_threadsafe(coro, loop)
    787 
    788     def on_ok(self):
    789         if not self.app.network:
    790             self.window.show_error(_("You are offline."))
    791             return
    792         if self.is_reverse:
    793             lightning_amount = self.send_amount
    794             onchain_amount = self.receive_amount
    795             self.app.protected(
    796                 'Do you want to do a reverse submarine swap?',
    797                 self.do_reverse_swap, (lightning_amount, onchain_amount))
    798         else:
    799             lightning_amount = self.receive_amount
    800             onchain_amount = self.send_amount
    801             self.app.protected(
    802                 'Do you want to do a submarine swap? '
    803                 'You will need to wait for the swap transaction to confirm.',
    804                 self.do_normal_swap, (lightning_amount, onchain_amount))