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