URI: 
       tlightning_channels.py - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
       tlightning_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))