1from typing import TYPE_CHECKING, Optional
2
3from PyQt5.QtCore import pyqtSignal
4from PyQt5.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton
5
6from electrum.i18n import _
7from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates
8from electrum.lnutil import ln_dummy_address
9from electrum.transaction import PartialTxOutput, PartialTransaction
10
11from .util import (WindowModalDialog, Buttons, OkButton, CancelButton,
12                   EnterButton, ColorScheme, WWLabel, read_QIcon, IconLabel)
13from .amountedit import BTCAmountEdit
14from .fee_slider import FeeSlider, FeeComboBox
15
16if TYPE_CHECKING:
17    from .main_window import ElectrumWindow
18
19CANNOT_RECEIVE_WARNING = """
20The requested amount is higher than what you can receive in your currently open channels.
21If you continue, your funds will be locked until the remote server can find a path to pay you.
22If the swap cannot be performed after 24h, you will be refunded.
23Do you want to continue?
24"""
25
26
27class SwapDialog(WindowModalDialog):
28
29    tx: Optional[PartialTransaction]
30    update_signal = pyqtSignal()
31
32    def __init__(self, window: 'ElectrumWindow'):
33        WindowModalDialog.__init__(self, window, _('Submarine Swap'))
34        self.window = window
35        self.config = window.config
36        self.lnworker = self.window.wallet.lnworker
37        self.swap_manager = self.lnworker.swap_manager
38        self.network = window.network
39        self.tx = None  # for the forward-swap only
40        self.is_reverse = True
41        vbox = QVBoxLayout(self)
42        self.description_label = WWLabel(self.get_description())
43        self.send_amount_e = BTCAmountEdit(self.window.get_decimal_point)
44        self.recv_amount_e = BTCAmountEdit(self.window.get_decimal_point)
45        self.max_button = EnterButton(_("Max"), self.spend_max)
46        self.max_button.setFixedWidth(100)
47        self.max_button.setCheckable(True)
48        self.toggle_button = QPushButton(u'\U000021c4')
49        # send_follows is used to know whether the send amount field / receive
50        # amount field should be adjusted after the fee slider was moved
51        self.send_follows = False
52        self.send_amount_e.follows = False
53        self.recv_amount_e.follows = False
54        self.toggle_button.clicked.connect(self.toggle_direction)
55        # textChanged is triggered for both user and automatic action
56        self.send_amount_e.textChanged.connect(self.on_send_edited)
57        self.recv_amount_e.textChanged.connect(self.on_recv_edited)
58        # textEdited is triggered only for user editing of the fields
59        self.send_amount_e.textEdited.connect(self.uncheck_max)
60        self.recv_amount_e.textEdited.connect(self.uncheck_max)
61        fee_slider = FeeSlider(self.window, self.config, self.fee_slider_callback)
62        fee_combo = FeeComboBox(fee_slider)
63        fee_slider.update()
64        self.fee_label = QLabel()
65        self.server_fee_label = QLabel()
66        vbox.addWidget(self.description_label)
67        h = QGridLayout()
68        self.send_label = IconLabel(text=_('You send')+':')
69        self.recv_label = IconLabel(text=_('You receive')+':')
70        h.addWidget(self.send_label, 1, 0)
71        h.addWidget(self.send_amount_e, 1, 1)
72        h.addWidget(self.max_button, 1, 2)
73        h.addWidget(self.toggle_button, 1, 3)
74        h.addWidget(self.recv_label, 2, 0)
75        h.addWidget(self.recv_amount_e, 2, 1)
76        h.addWidget(QLabel(_('Server fee')+':'), 4, 0)
77        h.addWidget(self.server_fee_label, 4, 1, 1, 2)
78        h.addWidget(QLabel(_('Mining fee')+':'), 5, 0)
79        h.addWidget(self.fee_label, 5, 1, 1, 2)
80        h.addWidget(fee_slider, 6, 1)
81        h.addWidget(fee_combo, 6, 2)
82        vbox.addLayout(h)
83        vbox.addStretch(1)
84        self.ok_button = OkButton(self)
85        self.ok_button.setDefault(True)
86        self.ok_button.setEnabled(False)
87        vbox.addLayout(Buttons(CancelButton(self), self.ok_button))
88        self.update_signal.connect(self.update)
89        self.update()
90
91    def fee_slider_callback(self, dyn, pos, fee_rate):
92        if dyn:
93            if self.config.use_mempool_fees():
94                self.config.set_key('depth_level', pos, False)
95            else:
96                self.config.set_key('fee_level', pos, False)
97        else:
98            self.config.set_key('fee_per_kb', fee_rate, False)
99        if self.send_follows:
100            self.on_recv_edited()
101        else:
102            self.on_send_edited()
103        self.update()
104
105    def toggle_direction(self):
106        self.is_reverse = not self.is_reverse
107        self.send_amount_e.setAmount(None)
108        self.recv_amount_e.setAmount(None)
109        self.max_button.setChecked(False)
110        self.update()
111
112    def spend_max(self):
113        if self.max_button.isChecked():
114            if self.is_reverse:
115                self._spend_max_reverse_swap()
116            else:
117                self._spend_max_forward_swap()
118        else:
119            self.send_amount_e.setAmount(None)
120        self.update_fee()
121        self.update_ok_button()
122
123    def uncheck_max(self):
124        self.max_button.setChecked(False)
125        self.update()
126
127    def _spend_max_forward_swap(self):
128        self._update_tx('!')
129        if self.tx:
130            amount = self.tx.output_value_for_address(ln_dummy_address())
131            max_swap_amt = self.swap_manager.get_max_amount()
132            max_recv_amt_ln = int(self.swap_manager.num_sats_can_receive())
133            max_recv_amt_oc = self.swap_manager.get_send_amount(max_recv_amt_ln, is_reverse=False) or float('inf')
134            max_amt = int(min(max_swap_amt, max_recv_amt_oc))
135            if amount > max_amt:
136                amount = max_amt
137                self._update_tx(amount)
138            if self.tx:
139                amount = self.tx.output_value_for_address(ln_dummy_address())
140                assert amount <= max_amt
141                self.send_amount_e.setAmount(amount)
142
143    def _spend_max_reverse_swap(self):
144        amount = min(self.lnworker.num_sats_can_send(), self.swap_manager.get_max_amount())
145        self.send_amount_e.setAmount(amount)
146
147    def on_send_edited(self):
148        if self.send_amount_e.follows:
149            return
150        self.send_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
151        send_amount = self.send_amount_e.get_amount()
152        recv_amount = self.swap_manager.get_recv_amount(send_amount, is_reverse=self.is_reverse)
153        if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
154            # cannot send this much on lightning
155            recv_amount = None
156        if (not self.is_reverse) and recv_amount and recv_amount > self.swap_manager.num_sats_can_receive():
157            # cannot receive this much on lightning
158            recv_amount = None
159        self.recv_amount_e.follows = True
160        self.recv_amount_e.setAmount(recv_amount)
161        self.recv_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
162        self.recv_amount_e.follows = False
163        self.send_follows = False
164        self._update_tx(send_amount)
165        self.update_fee()
166        self.update_ok_button()
167
168    def on_recv_edited(self):
169        if self.recv_amount_e.follows:
170            return
171        self.recv_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
172        recv_amount = self.recv_amount_e.get_amount()
173        send_amount = self.swap_manager.get_send_amount(recv_amount, is_reverse=self.is_reverse)
174        if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
175            send_amount = None
176        self.send_amount_e.follows = True
177        self.send_amount_e.setAmount(send_amount)
178        self.send_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
179        self.send_amount_e.follows = False
180        self.send_follows = True
181        self._update_tx(send_amount)
182        self.update_fee()
183        self.update_ok_button()
184
185    def update(self):
186        from .util import IconLabel
187        sm = self.swap_manager
188        send_icon = read_QIcon("lightning.png" if self.is_reverse else "bitcoin.png")
189        self.send_label.setIcon(send_icon)
190        recv_icon = read_QIcon("lightning.png" if not self.is_reverse else "bitcoin.png")
191        self.recv_label.setIcon(recv_icon)
192        self.description_label.setText(self.get_description())
193        self.description_label.repaint()  # macOS hack for #6269
194        server_mining_fee = sm.lockup_fee if self.is_reverse else sm.normal_fee
195        server_fee_str = '%.2f'%sm.percentage + '%  +  '  + self.window.format_amount(server_mining_fee) + ' ' + self.window.base_unit()
196        self.server_fee_label.setText(server_fee_str)
197        self.server_fee_label.repaint()  # macOS hack for #6269
198        self.update_tx()
199        self.update_fee()
200        self.update_ok_button()
201
202    def update_fee(self):
203        """Updates self.fee_label. No other side-effects."""
204        if self.is_reverse:
205            sm = self.swap_manager
206            fee = sm.get_claim_fee()
207        else:
208            fee = self.tx.get_fee() if self.tx else None
209        fee_text = self.window.format_amount(fee) + ' ' + self.window.base_unit() if fee else ''
210        self.fee_label.setText(fee_text)
211        self.fee_label.repaint()  # macOS hack for #6269
212
213    def run(self):
214        if not self.network:
215            self.window.show_error(_("You are offline."))
216            return
217        self.window.run_coroutine_from_thread(self.swap_manager.get_pairs(), lambda x: self.update_signal.emit())
218        if not self.exec_():
219            return
220        if self.is_reverse:
221            lightning_amount = self.send_amount_e.get_amount()
222            onchain_amount = self.recv_amount_e.get_amount()
223            if lightning_amount is None or onchain_amount is None:
224                return
225            coro = self.swap_manager.reverse_swap(
226                lightning_amount_sat=lightning_amount,
227                expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_claim_fee(),
228            )
229            self.window.run_coroutine_from_thread(coro)
230        else:
231            lightning_amount = self.recv_amount_e.get_amount()
232            onchain_amount = self.send_amount_e.get_amount()
233            if lightning_amount is None or onchain_amount is None:
234                return
235            if lightning_amount > self.swap_manager.num_sats_can_receive():
236                if not self.window.question(CANNOT_RECEIVE_WARNING):
237                    return
238            self.window.protect(self.do_normal_swap, (lightning_amount, onchain_amount))
239
240    def update_tx(self):
241        if self.is_reverse:
242            return
243        is_max = self.max_button.isChecked()
244        if is_max:
245            self._spend_max_forward_swap()
246        else:
247            onchain_amount = self.send_amount_e.get_amount()
248            self._update_tx(onchain_amount)
249
250    def _update_tx(self, onchain_amount):
251        """Updates self.tx. No other side-effects."""
252        if self.is_reverse:
253            return
254        if onchain_amount is None:
255            self.tx = None
256            return
257        outputs = [PartialTxOutput.from_address_and_value(ln_dummy_address(), onchain_amount)]
258        coins = self.window.get_coins()
259        try:
260            self.tx = self.window.wallet.make_unsigned_transaction(
261                coins=coins,
262                outputs=outputs)
263        except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
264            self.tx = None
265
266    def update_ok_button(self):
267        """Updates self.ok_button. No other side-effects."""
268        send_amount = self.send_amount_e.get_amount()
269        recv_amount = self.recv_amount_e.get_amount()
270        self.ok_button.setEnabled(
271            (send_amount is not None)
272            and (recv_amount is not None)
273            and (self.tx is not None or self.is_reverse)
274        )
275
276    def do_normal_swap(self, lightning_amount, onchain_amount, password):
277        tx = self.tx
278        assert tx
279        coro = self.swap_manager.normal_swap(
280            lightning_amount_sat=lightning_amount,
281            expected_onchain_amount_sat=onchain_amount,
282            password=password,
283            tx=tx,
284        )
285        self.window.run_coroutine_from_thread(coro)
286
287    def get_description(self):
288        onchain_funds = "onchain funds"
289        lightning_funds = "lightning funds"
290
291        return "Swap {fromType} for {toType}. This will increase your {capacityType} capacity. This service is powered by the Boltz backend.".format(
292            fromType=lightning_funds if self.is_reverse else onchain_funds,
293            toType=onchain_funds if self.is_reverse else lightning_funds,
294            capacityType="receiving" if self.is_reverse else "sending",
295        )
296