import asyncio import threading import math from typing import Union from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.i18n import _ from electrum.lnutil import ln_dummy_address from electrum.logging import get_logger from electrum.transaction import PartialTxOutput from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, profiler from .auth import AuthMixin, auth_protect from .qetypes import QEAmount from .qewallet import QEWallet class QESwapHelper(AuthMixin, QObject): _logger = get_logger(__name__) _wallet = None _sliderPos = 0 _rangeMin = 0 _rangeMax = 0 _tx = None _valid = False _userinfo = '' _tosend = QEAmount() _toreceive = QEAmount() _serverfeeperc = '' _serverfee = QEAmount() _miningfee = QEAmount() _isReverse = False _service_available = False _send_amount = 0 _receive_amount = 0 error = pyqtSignal([str], arguments=['message']) confirm = pyqtSignal([str], arguments=['message']) def __init__(self, parent=None): super().__init__(parent) walletChanged = pyqtSignal() @pyqtProperty(QEWallet, notify=walletChanged) def wallet(self): return self._wallet @wallet.setter def wallet(self, wallet: QEWallet): if self._wallet != wallet: self._wallet = wallet self.init_swap_slider_range() self.walletChanged.emit() sliderPosChanged = pyqtSignal() @pyqtProperty(float, notify=sliderPosChanged) def sliderPos(self): return self._sliderPos @sliderPos.setter def sliderPos(self, sliderPos): if self._sliderPos != sliderPos: self._sliderPos = sliderPos self.swap_slider_moved() self.sliderPosChanged.emit() rangeMinChanged = pyqtSignal() @pyqtProperty(float, notify=rangeMinChanged) def rangeMin(self): return self._rangeMin @rangeMin.setter def rangeMin(self, rangeMin): if self._rangeMin != rangeMin: self._rangeMin = rangeMin self.rangeMinChanged.emit() rangeMaxChanged = pyqtSignal() @pyqtProperty(float, notify=rangeMaxChanged) def rangeMax(self): return self._rangeMax @rangeMax.setter def rangeMax(self, rangeMax): if self._rangeMax != rangeMax: self._rangeMax = rangeMax self.rangeMaxChanged.emit() validChanged = pyqtSignal() @pyqtProperty(bool, notify=validChanged) def valid(self): return self._valid @valid.setter def valid(self, valid): if self._valid != valid: self._valid = valid self.validChanged.emit() userinfoChanged = pyqtSignal() @pyqtProperty(str, notify=userinfoChanged) def userinfo(self): return self._userinfo @userinfo.setter def userinfo(self, userinfo): if self._userinfo != userinfo: self._userinfo = userinfo self.userinfoChanged.emit() tosendChanged = pyqtSignal() @pyqtProperty(QEAmount, notify=tosendChanged) def tosend(self): return self._tosend @tosend.setter def tosend(self, tosend): if self._tosend != tosend: self._tosend = tosend self.tosendChanged.emit() toreceiveChanged = pyqtSignal() @pyqtProperty(QEAmount, notify=toreceiveChanged) def toreceive(self): return self._toreceive @toreceive.setter def toreceive(self, toreceive): if self._toreceive != toreceive: self._toreceive = toreceive self.toreceiveChanged.emit() serverfeeChanged = pyqtSignal() @pyqtProperty(QEAmount, notify=serverfeeChanged) def serverfee(self): return self._serverfee @serverfee.setter def serverfee(self, serverfee): if self._serverfee != serverfee: self._serverfee = serverfee self.serverfeeChanged.emit() serverfeepercChanged = pyqtSignal() @pyqtProperty(str, notify=serverfeepercChanged) def serverfeeperc(self): return self._serverfeeperc @serverfeeperc.setter def serverfeeperc(self, serverfeeperc): if self._serverfeeperc != serverfeeperc: self._serverfeeperc = serverfeeperc self.serverfeepercChanged.emit() miningfeeChanged = pyqtSignal() @pyqtProperty(QEAmount, notify=miningfeeChanged) def miningfee(self): return self._miningfee @miningfee.setter def miningfee(self, miningfee): if self._miningfee != miningfee: self._miningfee = miningfee self.miningfeeChanged.emit() isReverseChanged = pyqtSignal() @pyqtProperty(bool, notify=isReverseChanged) def isReverse(self): return self._isReverse @isReverse.setter def isReverse(self, isReverse): if self._isReverse != isReverse: self._isReverse = isReverse self.isReverseChanged.emit() def init_swap_slider_range(self): lnworker = self._wallet.wallet.lnworker swap_manager = lnworker.swap_manager try: asyncio.run(swap_manager.get_pairs()) self._service_available = True except Exception as e: self.error.emit(_('Swap service unavailable')) self._logger.error(f'could not get pairs for swap: {repr(e)}') return """Sets the minimal and maximal amount that can be swapped for the swap slider.""" # tx is updated again afterwards with send_amount in case of normal swap # this is just to estimate the maximal spendable onchain amount for HTLC self.update_tx('!') try: max_onchain_spend = self._tx.output_value_for_address(ln_dummy_address()) except AttributeError: # happens if there are no utxos max_onchain_spend = 0 reverse = int(min(lnworker.num_sats_can_send(), swap_manager.get_max_amount())) max_recv_amt_ln = int(swap_manager.num_sats_can_receive()) max_recv_amt_oc = swap_manager.get_send_amount(max_recv_amt_ln, is_reverse=False) or 0 forward = int(min(max_recv_amt_oc, # maximally supported swap amount by provider swap_manager.get_max_amount(), max_onchain_spend)) # we expect range to adjust the value of the swap slider to be in the # correct range, i.e., to correct an overflow when reducing the limits self._logger.debug(f'Slider range {-reverse} - {forward}') self.rangeMin = -reverse self.rangeMax = forward self.swap_slider_moved() @profiler def update_tx(self, onchain_amount: Union[int, str]): """Updates the transaction associated with a forward swap.""" if onchain_amount is None: self._tx = None self.valid = False return outputs = [PartialTxOutput.from_address_and_value(ln_dummy_address(), onchain_amount)] coins = self._wallet.wallet.get_spendable_coins(None) try: self._tx = self._wallet.wallet.make_unsigned_transaction( coins=coins, outputs=outputs) except (NotEnoughFunds, NoDynamicFeeEstimates): self._tx = None self.valid = False def swap_slider_moved(self): if not self._service_available: return position = int(self._sliderPos) swap_manager = self._wallet.wallet.lnworker.swap_manager # pay_amount and receive_amounts are always with fees already included # so they reflect the net balance change after the swap if position < 0: # reverse swap self.userinfo = _('Adds Lightning receiving capacity.') self.isReverse = True pay_amount = abs(position) self._send_amount = pay_amount self.tosend = QEAmount(amount_sat=pay_amount) receive_amount = swap_manager.get_recv_amount( send_amount=pay_amount, is_reverse=True) self._receive_amount = receive_amount self.toreceive = QEAmount(amount_sat=receive_amount) # fee breakdown self.serverfeeperc = f'{swap_manager.percentage:0.1f}%' serverfee = math.ceil(swap_manager.percentage * pay_amount / 100) + swap_manager.lockup_fee self.serverfee = QEAmount(amount_sat=serverfee) self.miningfee = QEAmount(amount_sat=swap_manager.get_claim_fee()) else: # forward (normal) swap self.userinfo = _('Adds Lightning sending capacity.') self.isReverse = False self._send_amount = position self.update_tx(self._send_amount) # add lockup fees, but the swap amount is position pay_amount = position + self._tx.get_fee() if self._tx else 0 self.tosend = QEAmount(amount_sat=pay_amount) receive_amount = swap_manager.get_recv_amount(send_amount=position, is_reverse=False) self._receive_amount = receive_amount self.toreceive = QEAmount(amount_sat=receive_amount) # fee breakdown self.serverfeeperc = f'{swap_manager.percentage:0.1f}%' serverfee = math.ceil(swap_manager.percentage * pay_amount / 100) + swap_manager.normal_fee self.serverfee = QEAmount(amount_sat=serverfee) self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) if self._tx else QEAmount() if pay_amount and receive_amount: self.valid = True else: # add more nuanced error reporting? self.userinfo = _('Swap below minimal swap size, change the slider.') self.valid = False def do_normal_swap(self, lightning_amount, onchain_amount): assert self._tx if lightning_amount is None or onchain_amount is None: return loop = self._wallet.wallet.network.asyncio_loop coro = self._wallet.wallet.lnworker.swap_manager.normal_swap( lightning_amount_sat=lightning_amount, expected_onchain_amount_sat=onchain_amount, password=self._wallet.password, tx=self._tx, ) def swap_task(): try: fut = asyncio.run_coroutine_threadsafe(coro, loop) result = fut.result() except Exception as e: self._logger.error(str(e)) self.error.emit(str(e)) threading.Thread(target=swap_task).start() def do_reverse_swap(self, lightning_amount, onchain_amount): if lightning_amount is None or onchain_amount is None: return swap_manager = self._wallet.wallet.lnworker.swap_manager loop = self._wallet.wallet.network.asyncio_loop coro = swap_manager.reverse_swap( lightning_amount_sat=lightning_amount, expected_onchain_amount_sat=onchain_amount + swap_manager.get_claim_fee(), ) def swap_task(): try: fut = asyncio.run_coroutine_threadsafe(coro, loop) result = fut.result() except Exception as e: self._logger.error(str(e)) self.error.emit(str(e)) threading.Thread(target=swap_task).start() @pyqtSlot() @pyqtSlot(bool) def executeSwap(self, confirm=False): if not self._wallet.wallet.network: self.error.emit(_("You are offline.")) return if confirm: self._do_execute_swap() return if self.isReverse: self.confirm.emit(_('Do you want to do a reverse submarine swap?')) else: self.confirm.emit(_('Do you want to do a submarine swap? ' 'You will need to wait for the swap transaction to confirm.' )) @auth_protect def _do_execute_swap(self): if self.isReverse: lightning_amount = self._send_amount onchain_amount = self._receive_amount self.do_reverse_swap(lightning_amount, onchain_amount) else: lightning_amount = self._receive_amount onchain_amount = self._send_amount self.do_normal_swap(lightning_amount, onchain_amount)