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