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