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))